Compare commits

...

6 Commits

Author SHA1 Message Date
skymike03
56c87ab05f v2.3.2.8 (2025.11.20)
- Improving virtual keyboard navigation when filtering game list (thanks elieserdejesus)
- web interface : Add modal for displaying support messages
- normalize sizes in bytes when not in french
- Refactor control navigation and improve button rendering in UI
2025-11-20 23:19:31 +01:00
skymike03
b12d645fbf Add support modal for displaying formatted support messages 2025-11-20 18:20:12 +01:00
skymike03
04e68adef0 Merge branch 'main' of https://github.com/RetroGameSets/RGSX 2025-11-20 18:02:29 +01:00
skymike03
52f2b960c2 Refactor control navigation and improve button rendering in UI 2025-11-20 18:02:26 +01:00
RGS
1ea604840e Merge pull request #33 from elieserdejesus/main
Improving virtual keyboard navigation when filtering game list to circular navigate
2025-11-20 17:37:59 +01:00
Elieser de Jesus
802696e78f Improving virtual keyboard navigation when filtering game list
The general idea is allow something like "circular buffer" logic when selecting a key in the virtual keyboard.

When the virtual keyboard is displayed:
 - If you are in the first line and press UP jump to last line
 - If you are in the last line and press DOWN jump to first line
 - If you are in the first col and press LEFT jump to last col
 - If you are in the last col and press RIGHT jump to first col
2025-11-20 12:55:30 -03:00
13 changed files with 543 additions and 156 deletions

View File

@@ -13,7 +13,7 @@ except Exception:
pygame = None # type: ignore
# Version actuelle de l'application
app_version = "2.3.2.7"
app_version = "2.3.2.8"
def get_application_root():

View File

@@ -517,6 +517,8 @@ def handle_controls(event, sources, joystick, screen):
max_row = len(keyboard_layout) - 1
max_col = len(keyboard_layout[row]) - 1
if is_input_matched(event, "up"):
if row == 0: # if you are in the first row and press UP jump to last row
row = max_row + (1 if col <= 5 else 0)
if row > 0:
config.selected_key = (row - 1, min(col, len(keyboard_layout[row - 1]) - 1))
config.repeat_action = "up"
@@ -525,6 +527,8 @@ def handle_controls(event, sources, joystick, screen):
config.repeat_key = event.key if event.type == pygame.KEYDOWN else event.button if event.type == pygame.JOYBUTTONDOWN else (event.axis, 1 if event.value > 0 else -1) if event.type == pygame.JOYAXISMOTION else event.value
config.needs_redraw = True
elif is_input_matched(event, "down"):
if (col <= 5 and row == max_row) or (col > 5 and row == max_row-1): # if you are in the last row and press DOWN jump to first row
row = -1
if row < max_row:
config.selected_key = (row + 1, min(col, len(keyboard_layout[row + 1]) - 1))
config.repeat_action = "down"
@@ -533,6 +537,8 @@ def handle_controls(event, sources, joystick, screen):
config.repeat_key = event.key if event.type == pygame.KEYDOWN else event.button if event.type == pygame.JOYBUTTONDOWN else (event.axis, 1 if event.value > 0 else -1) if event.type == pygame.JOYAXISMOTION else event.value
config.needs_redraw = True
elif is_input_matched(event, "left"):
if col == 0: # if you are in the first col and press LEFT jump to last col
col = max_col + 1
if col > 0:
config.selected_key = (row, col - 1)
config.repeat_action = "left"
@@ -541,6 +547,8 @@ def handle_controls(event, sources, joystick, screen):
config.repeat_key = event.key if event.type == pygame.KEYDOWN else event.button if event.type == pygame.JOYBUTTONDOWN else (event.axis, 1 if event.value > 0 else -1) if event.type == pygame.JOYAXISMOTION else event.value
config.needs_redraw = True
elif is_input_matched(event, "right"):
if col == max_col: # if you are in the last col and press RIGHT jump to first col
col = -1
if col < max_col:
config.selected_key = (row, col + 1)
config.repeat_action = "right"
@@ -1983,69 +1991,89 @@ def handle_controls(event, sources, joystick, screen):
if filter_dict:
config.game_filter_obj.load_from_dict(filter_dict)
# Construire la liste des options (comme dans draw_filter_advanced)
options = []
options.append(('header', 'region_title'))
for region in GameFilters.REGIONS:
options.append(('region', region))
options.append(('separator', ''))
options.append(('header', 'other_options'))
options.append(('toggle', 'hide_non_release'))
options.append(('toggle', 'one_rom_per_game'))
options.append(('button_inline', 'priority_config'))
# Construire la liste linéaire des éléments sélectionnables (pour simplifier l'indexation)
# Régions individuelles
num_regions = len(GameFilters.REGIONS)
# Options toggle/button
num_other_options = 3 # hide_non_release, one_rom_per_game, priority_config
# Boutons en bas
num_buttons = 3 # apply, reset, back
# Boutons séparés (3 boutons au total)
buttons = [
('button', 'apply'),
('button', 'reset'),
('button', 'back')
]
# Total d'éléments sélectionnables
total_items = len(options) + len(buttons)
total_items = num_regions + num_other_options + num_buttons
if is_input_matched(event, "up"):
# Chercher l'option sélectionnable précédente
config.selected_filter_option = (config.selected_filter_option - 1) % total_items
while config.selected_filter_option < len(options) and options[config.selected_filter_option][0] in ['header', 'separator']:
# Navigation verticale dans la grille ou entre sections
if config.selected_filter_option < num_regions:
# Dans la grille des régions (3 colonnes)
if config.selected_filter_option >= 3:
# Monter d'une ligne
config.selected_filter_option -= 3
else:
# Déjà en haut, aller aux boutons
config.selected_filter_option = total_items - 2 # Bouton du milieu (reset)
else:
# Dans les options ou boutons, monter normalement
config.selected_filter_option = (config.selected_filter_option - 1) % total_items
config.needs_redraw = True
update_key_state("up", True, event.type, event.key if event.type == pygame.KEYDOWN else
event.button if event.type == pygame.JOYBUTTONDOWN else
(event.axis, event.value) if event.type == pygame.JOYAXISMOTION else
event.value)
elif is_input_matched(event, "down"):
# Chercher l'option sélectionnable suivante
config.selected_filter_option = (config.selected_filter_option + 1) % total_items
while config.selected_filter_option < len(options) and options[config.selected_filter_option][0] in ['header', 'separator']:
# Navigation verticale
if config.selected_filter_option < num_regions:
# Dans la grille des régions
if config.selected_filter_option + 3 < num_regions:
# Descendre d'une ligne
config.selected_filter_option += 3
else:
# Aller aux autres options
config.selected_filter_option = num_regions
else:
# Dans les options ou boutons, descendre normalement
config.selected_filter_option = (config.selected_filter_option + 1) % total_items
config.needs_redraw = True
update_key_state("down", True, event.type, event.key if event.type == pygame.KEYDOWN else
event.button if event.type == pygame.JOYBUTTONDOWN else
(event.axis, event.value) if event.type == pygame.JOYAXISMOTION else
event.value)
elif is_input_matched(event, "left") or is_input_matched(event, "right"):
# Navigation gauche/droite uniquement pour les boutons en bas
if config.selected_filter_option >= len(options):
button_index = config.selected_filter_option - len(options)
if is_input_matched(event, "left"):
button_index = (button_index - 1) % len(buttons)
else:
button_index = (button_index + 1) % len(buttons)
config.selected_filter_option = len(options) + button_index
elif is_input_matched(event, "left"):
# Navigation horizontale
if config.selected_filter_option < num_regions:
# Dans la grille des régions
if config.selected_filter_option % 3 > 0:
config.selected_filter_option -= 1
config.needs_redraw = True
elif config.selected_filter_option >= num_regions + num_other_options:
# Dans les boutons en bas
button_idx = config.selected_filter_option - (num_regions + num_other_options)
button_idx = (button_idx - 1) % num_buttons
config.selected_filter_option = num_regions + num_other_options + button_idx
config.needs_redraw = True
elif is_input_matched(event, "right"):
# Navigation horizontale
if config.selected_filter_option < num_regions:
# Dans la grille des régions
if config.selected_filter_option % 3 < 2 and config.selected_filter_option + 1 < num_regions:
config.selected_filter_option += 1
config.needs_redraw = True
elif config.selected_filter_option >= num_regions + num_other_options:
# Dans les boutons en bas
button_idx = config.selected_filter_option - (num_regions + num_other_options)
button_idx = (button_idx + 1) % num_buttons
config.selected_filter_option = num_regions + num_other_options + button_idx
config.needs_redraw = True
elif is_input_matched(event, "confirm"):
# Déterminer si c'est une option ou un bouton
if config.selected_filter_option < len(options):
option_type, *option_data = options[config.selected_filter_option]
else:
# C'est un bouton
button_index = config.selected_filter_option - len(options)
option_type, *option_data = buttons[button_index]
if option_type == 'region':
# Basculer filtre région: include ↔ exclude (include par défaut)
region = option_data[0]
# Déterminer quel élément a été sélectionné
if config.selected_filter_option < num_regions:
# C'est une région
region = GameFilters.REGIONS[config.selected_filter_option]
current_state = config.game_filter_obj.region_filters.get(region, 'include')
if current_state == 'include':
config.game_filter_obj.region_filters[region] = 'exclude'
@@ -2054,28 +2082,31 @@ def handle_controls(event, sources, joystick, screen):
config.needs_redraw = True
logger.debug(f"Filtre région {region} modifié: {config.game_filter_obj.region_filters[region]}")
elif option_type == 'toggle':
toggle_name = option_data[0]
if toggle_name == 'hide_non_release':
elif config.selected_filter_option < num_regions + num_other_options:
# C'est une autre option
option_idx = config.selected_filter_option - num_regions
if option_idx == 0:
# hide_non_release
config.game_filter_obj.hide_non_release = not config.game_filter_obj.hide_non_release
elif toggle_name == 'one_rom_per_game':
config.needs_redraw = True
logger.debug("Toggle hide_non_release modifié")
elif option_idx == 1:
# one_rom_per_game
config.game_filter_obj.one_rom_per_game = not config.game_filter_obj.one_rom_per_game
config.needs_redraw = True
logger.debug(f"Toggle {toggle_name} modifié")
elif option_type == 'button_inline':
button_name = option_data[0]
if button_name == 'priority_config':
# Ouvrir le menu de configuration de priorité
config.needs_redraw = True
logger.debug("Toggle one_rom_per_game modifié")
elif option_idx == 2:
# priority_config
config.menu_state = "filter_priority_config"
config.selected_priority_index = 0
config.needs_redraw = True
logger.debug("Ouverture configuration priorité régions")
elif option_type == 'button':
button_name = option_data[0]
if button_name == 'apply':
# Appliquer les filtres
else:
# C'est un bouton
button_idx = config.selected_filter_option - (num_regions + num_other_options)
if button_idx == 0:
# Apply
save_game_filters(config.game_filter_obj.to_dict())
# Appliquer aux jeux actuels
@@ -2092,8 +2123,8 @@ def handle_controls(event, sources, joystick, screen):
config.needs_redraw = True
logger.debug("Filtres appliqués")
elif button_name == 'reset':
# Réinitialiser les filtres
elif button_idx == 1:
# Reset
config.game_filter_obj.reset()
save_game_filters(config.game_filter_obj.to_dict())
config.filtered_games = config.games
@@ -2101,8 +2132,8 @@ def handle_controls(event, sources, joystick, screen):
config.needs_redraw = True
logger.debug("Filtres réinitialisés")
elif button_name == 'back':
# Retour sans appliquer
elif button_idx == 2:
# Back
config.menu_state = "game"
config.needs_redraw = True
logger.debug("Retour sans appliquer les filtres")
@@ -2593,4 +2624,4 @@ def get_emergency_controls():
# manette basique
"confirm_joy": {"type": "button", "button": 0},
"cancel_joy": {"type": "button", "button": 1},
}
}

View File

@@ -257,7 +257,18 @@ def draw_stylized_button(screen, text, x, y, width, height, selected=False):
pygame.draw.rect(glow_surface, THEME_COLORS["fond_lignes"] + (50,), (5, 5, width, height), border_radius=12)
screen.blit(glow_surface, (x - 5, y - 5))
screen.blit(button_surface, (x, y))
# Vérifier si le texte dépasse la largeur disponible
text_surface = config.font.render(text, True, THEME_COLORS["text"])
available_width = width - 20 # Marge de 10px de chaque côté
if text_surface.get_width() > available_width:
# Tronquer le texte avec "..."
truncated_text = text
while text_surface.get_width() > available_width and len(truncated_text) > 0:
truncated_text = truncated_text[:-1]
text_surface = config.font.render(truncated_text + "...", True, THEME_COLORS["text"])
text_rect = text_surface.get_rect(center=(x + width // 2, y + height // 2))
screen.blit(text_surface, text_rect)
@@ -1682,11 +1693,23 @@ def draw_language_menu(screen):
vpad = max(8, min(14, int(title_surface.get_height() * 0.4)))
title_bg_rect = title_rect.inflate(hpad, vpad)
# Dimensions responsives des boutons
# Largeur bornée entre 260 et 380px (~40% de la largeur écran)
button_width = max(260, min(380, int(config.screen_width * 0.4)))
# Hauteur réduite et responsive (env. 5.5% de la hauteur écran), bornée 28..56
button_height = max(28, min(56, int(config.screen_height * 0.055)))
# Calculer hauteur dynamique basée sur la taille de police
sample_text = config.font.render("Sample", True, THEME_COLORS["text"])
font_height = sample_text.get_height()
# Calculer largeur maximale nécessaire pour les noms de langues
max_text_width = 0
for lang_code in available_languages:
lang_name = get_language_name(lang_code)
text_surface = config.font.render(lang_name, True, THEME_COLORS["text"])
if text_surface.get_width() > max_text_width:
max_text_width = text_surface.get_width()
# Largeur bornée entre valeur calculée et limites raisonnables
button_width = max(260, min(500, max_text_width + 60))
# Hauteur réduite et responsive (env. 5.5% de la hauteur écran), bornée mais aussi fonction de la police
# Augmenter le padding pour grandes polices
button_height = max(28, min(70, max(int(config.screen_height * 0.055), font_height + 20)))
# Espacement vertical proportionnel et borné
button_spacing = max(8, int(button_height * 0.35))
@@ -1737,8 +1760,17 @@ def draw_language_menu(screen):
pygame.draw.rect(screen, button_color, (button_x, button_y, button_width, button_height), border_radius=10)
pygame.draw.rect(screen, THEME_COLORS["border"], (button_x, button_y, button_width, button_height), 2, border_radius=10)
# Texte du bouton
# Texte avec gestion du dépassement
text_surface = config.font.render(lang_name, True, THEME_COLORS["text"])
available_width = button_width - 20 # Marge de 10px de chaque côté
if text_surface.get_width() > available_width:
# Tronquer le texte avec "..."
truncated_text = lang_name
while text_surface.get_width() > available_width and len(truncated_text) > 0:
truncated_text = truncated_text[:-1]
text_surface = config.font.render(truncated_text + "...", True, THEME_COLORS["text"])
text_rect = text_surface.get_rect(center=(button_x + button_width // 2, button_y + button_height // 2))
screen.blit(text_surface, text_rect)
@@ -1864,8 +1896,20 @@ def draw_pause_menu(screen, selected_option):
_("menu_support"), # 6 -> support
_("menu_quit") # 7 -> quit
]
menu_width = int(config.screen_width * 0.6)
button_height = int(config.screen_height * 0.048)
# Calculer hauteur dynamique basée sur la taille de police
sample_text = config.font.render("Sample", True, THEME_COLORS["text"])
font_height = sample_text.get_height()
button_height = max(int(config.screen_height * 0.048), font_height + 20)
# Calculer largeur maximale nécessaire pour le texte
max_text_width = 0
for option in options:
text_surface = config.font.render(option, True, THEME_COLORS["text"])
if text_surface.get_width() > max_text_width:
max_text_width = text_surface.get_width()
# Largeur du menu basée sur le texte le plus long + marges
menu_width = min(int(config.screen_width * 0.8), max(int(config.screen_width * 0.5), max_text_width + 80))
margin_top_bottom = 24
menu_height = len(options) * (button_height + 12) + 2 * margin_top_bottom
menu_x = (config.screen_width - menu_width) // 2
@@ -1910,8 +1954,23 @@ def draw_pause_menu(screen, selected_option):
def _draw_submenu_generic(screen, title, options, selected_index):
"""Helper générique pour dessiner un sous-menu hiérarchique."""
screen.blit(OVERLAY, (0, 0))
menu_width = int(config.screen_width * 0.72)
button_height = int(config.screen_height * 0.045)
# Calculer hauteur dynamique basée sur la taille de police
sample_text = config.font.render("Sample", True, THEME_COLORS["text"])
font_height = sample_text.get_height()
button_height = max(int(config.screen_height * 0.045), font_height + 18)
# Calculer largeur maximale nécessaire pour le texte (titre + options)
max_text_width = 0
title_surface = config.font.render(title, True, THEME_COLORS["text"])
max_text_width = title_surface.get_width()
for option in options:
text_surface = config.font.render(option, True, THEME_COLORS["text"])
if text_surface.get_width() > max_text_width:
max_text_width = text_surface.get_width()
# Largeur du menu basée sur le texte le plus long + marges
menu_width = min(int(config.screen_width * 0.85), max(int(config.screen_width * 0.55), max_text_width + 80))
margin_top_bottom = 26
menu_height = (len(options)+1) * (button_height + 10) + 2 * margin_top_bottom # +1 pour le titre
menu_x = (config.screen_width - menu_width) // 2
@@ -2623,7 +2682,10 @@ def draw_confirm_dialog(screen):
wrapped_message = wrap_text(message, config.font, config.screen_width - 80)
line_height = config.font.get_height() + 5
text_height = len(wrapped_message) * line_height
button_height = int(config.screen_height * 0.0463)
# Adapter hauteur bouton en fonction de la taille de police
sample_text = config.font.render("Sample", True, THEME_COLORS["text"])
font_height = sample_text.get_height()
button_height = max(int(config.screen_height * 0.0463), font_height + 15)
margin_top_bottom = 20
rect_height = text_height + button_height + 2 * margin_top_bottom
max_text_width = max([config.font.size(line)[0] for line in wrapped_message], default=300)
@@ -2656,7 +2718,10 @@ def draw_reload_games_data_dialog(screen):
wrapped_message = wrap_text(message, config.small_font, config.screen_width - 80)
line_height = config.small_font.get_height() + 5
text_height = len(wrapped_message) * line_height
button_height = int(config.screen_height * 0.0463)
# Adapter hauteur bouton en fonction de la taille de police
sample_text = config.small_font.render("Sample", True, THEME_COLORS["text"])
font_height = sample_text.get_height()
button_height = max(int(config.screen_height * 0.0463), font_height + 15)
margin_top_bottom = 20
rect_height = text_height + button_height + 2 * margin_top_bottom
max_text_width = max([config.small_font.size(line)[0] for line in wrapped_message], default=300)
@@ -3376,11 +3441,24 @@ def draw_filter_menu_choice(screen):
_("filter_advanced")
]
# Calculer hauteur dynamique basée sur la taille de police
sample_text = config.font.render("Sample", True, THEME_COLORS["text"])
font_height = sample_text.get_height()
button_height = max(60, font_height + 30)
# Calculer largeur maximale nécessaire pour le texte
max_text_width = 0
for option in options:
text_surface = config.font.render(option, True, THEME_COLORS["text"])
if text_surface.get_width() > max_text_width:
max_text_width = text_surface.get_width()
# Largeur du bouton basée sur le texte le plus long + marges
button_width = max(400, max_text_width + 80)
# Calculer positions
menu_y = 150
button_height = 60
button_spacing = 20
button_width = 600
for i, option in enumerate(options):
y = menu_y + i * (button_height + button_spacing)
@@ -3398,8 +3476,17 @@ def draw_filter_menu_choice(screen):
pygame.draw.rect(screen, color, (x, y, button_width, button_height), border_radius=12)
pygame.draw.rect(screen, border_color, (x, y, button_width, button_height), 3, border_radius=12)
# Texte
# Texte avec gestion du dépassement
text_surface = config.font.render(option, True, THEME_COLORS["text"])
available_width = button_width - 40 # Marge de 20px de chaque côté
if text_surface.get_width() > available_width:
# Tronquer le texte avec "..."
truncated_text = option
while text_surface.get_width() > available_width and len(truncated_text) > 0:
truncated_text = truncated_text[:-1]
text_surface = config.font.render(truncated_text + "...", True, THEME_COLORS["text"])
text_rect = text_surface.get_rect(center=(config.screen_width // 2, y + button_height // 2))
screen.blit(text_surface, text_rect)
@@ -3410,12 +3497,6 @@ def draw_filter_advanced(screen):
screen.blit(OVERLAY, (0, 0))
# Titre
title = _("filter_advanced_title")
title_surface = config.title_font.render(title, True, THEME_COLORS["text"])
title_rect = title_surface.get_rect(center=(config.screen_width // 2, 40))
screen.blit(title_surface, title_rect)
# Initialiser le filtre si nécessaire
if not hasattr(config, 'game_filter_obj'):
config.game_filter_obj = GameFilters()
@@ -3425,18 +3506,15 @@ def draw_filter_advanced(screen):
if filter_dict:
config.game_filter_obj.load_from_dict(filter_dict)
# Zones d'affichage
start_y = 100
line_height = 50
current_y = start_y
# Liste des options
# Liste des options (sans les régions pour l'instant)
options = []
# Section Régions
# Section Régions (titre seulement)
region_title = _("filter_region_title")
options.append(('header', region_title))
# On va afficher les régions en grille 3x3, donc on ajoute des placeholders
regions_list = []
for region in GameFilters.REGIONS:
region_key = f"filter_region_{region.lower()}"
region_label = _(region_key)
@@ -3449,7 +3527,10 @@ def draw_filter_advanced(screen):
status = f"[V] {_('filter_region_include')}"
color = THEME_COLORS["green"]
options.append(('region', region, f"{region_label}: {status}", color))
regions_list.append(('region', region, f"{region_label}: {status}", color))
# Ajouter les régions comme une seule entrée "grid" dans options
options.append(('region_grid', regions_list))
# Section Autres options
options.append(('separator', ''))
@@ -3477,31 +3558,152 @@ def draw_filter_advanced(screen):
if not hasattr(config, 'selected_filter_option'):
config.selected_filter_option = 0
# S'assurer que l'index est valide (options + 3 boutons)
total_items = len(options) + len(buttons)
# Calculer le nombre total d'items sélectionnables (régions individuelles + autres options + boutons)
total_items = len(regions_list) + len([opt for opt in options if opt[0] in ['toggle', 'button_inline']]) + len(buttons)
if config.selected_filter_option >= total_items:
config.selected_filter_option = total_items - 1
for i, option in enumerate(options):
# Calculer d'abord la hauteur totale nécessaire
# Adapter la hauteur en fonction de la taille de police
sample_text = config.font.render("Sample", True, THEME_COLORS["text"])
font_height = sample_text.get_height()
line_height = max(50, font_height + 30)
item_height = max(45, font_height + 20)
item_spacing_y = 10
items_per_row = 3
# Titre
title_height = 60
# Hauteur du header régions
header_height = line_height
# Hauteur de la grille de régions
num_rows = (len(regions_list) + items_per_row - 1) // items_per_row
grid_height = num_rows * (item_height + item_spacing_y)
# Hauteur du séparateur
separator_height = 10
# Hauteur du header autres options
header2_height = line_height
# Hauteur des autres options (3 options)
num_other_options = len([opt for opt in options if opt[0] in ['toggle', 'button_inline']])
other_options_height = num_other_options * (item_height + 10)
# Hauteur des boutons
# Adapter en fonction de la taille de police
sample_text = config.font.render("Sample", True, THEME_COLORS["text"])
font_height = sample_text.get_height()
button_height = max(50, font_height + 20)
buttons_top_margin = 30
# Hauteur totale du contenu
total_content_height = (title_height + header_height + grid_height + separator_height +
header2_height + other_options_height + buttons_top_margin + button_height)
# Calculer position de départ pour centrer verticalement
control_bar_estimated_height = 80
available_height = config.screen_height - control_bar_estimated_height
start_y = (available_height - total_content_height) // 2
if start_y < 20:
start_y = 20 # Marge minimale du haut
current_y = start_y
# Titre
title = _("filter_advanced_title")
title_surface = config.title_font.render(title, True, THEME_COLORS["text"])
title_rect = title_surface.get_rect(center=(config.screen_width // 2, current_y + 20))
screen.blit(title_surface, title_rect)
current_y += title_height
region_index_start = 0 # Les régions commencent à l'index 0
for option in options:
option_type = option[0]
if option_type == 'header':
# En-tête de section
text_surface = config.font.render(option[1], True, THEME_COLORS["title_text"])
screen.blit(text_surface, (100, current_y))
text_rect = text_surface.get_rect(center=(config.screen_width // 2, current_y + 20))
screen.blit(text_surface, text_rect)
current_y += line_height
elif option_type == 'separator':
current_y += 10
current_y += separator_height
elif option_type in ['region', 'toggle', 'button_inline']:
# Option sélectionnable
x = 120
width = config.screen_width - 240
height = 45
elif option_type == 'region_grid':
# Afficher les régions en grille 3 par ligne
regions_data = option[1]
item_spacing_x = 20
# Calculer la largeur maximale nécessaire pour les boutons de régions
max_region_width = 0
for region_data in regions_data:
text = region_data[2]
text_surface = config.font.render(text, True, THEME_COLORS["text"])
text_width = text_surface.get_width() + 30 # Padding de 30px
if text_width > max_region_width:
max_region_width = text_width
# Largeur minimale de 200px
item_width = max(max_region_width, 200)
# Calculer la largeur totale de la grille
total_grid_width = items_per_row * item_width + (items_per_row - 1) * item_spacing_x
grid_start_x = (config.screen_width - total_grid_width) // 2
for idx, region_data in enumerate(regions_data):
row = idx // items_per_row
col = idx % items_per_row
x = grid_start_x + col * (item_width + item_spacing_x)
y = current_y + row * (item_height + item_spacing_y)
# Index global de cette région
global_idx = region_index_start + idx
# Couleur selon sélection
if global_idx == config.selected_filter_option:
bg_color = THEME_COLORS["button_selected"]
border_color = THEME_COLORS["border_selected"]
else:
bg_color = THEME_COLORS["button_idle"]
border_color = THEME_COLORS["border"]
# Dessiner fond
pygame.draw.rect(screen, bg_color, (x, y, item_width, item_height), border_radius=8)
pygame.draw.rect(screen, border_color, (x, y, item_width, item_height), 2, border_radius=8)
# Texte centré
text = region_data[2]
text_color = region_data[3]
text_surface = config.font.render(text, True, text_color)
text_rect = text_surface.get_rect(center=(x + item_width // 2, y + item_height // 2))
screen.blit(text_surface, text_rect)
# Calculer la hauteur occupée par la grille
current_y += num_rows * (item_height + item_spacing_y) + 10
elif option_type in ['toggle', 'button_inline']:
# Option sélectionnable - largeur adaptée au texte
text = option[2]
text_surface = config.font.render(text, True, THEME_COLORS["text"])
text_width = text_surface.get_width()
# Largeur avec padding
width = text_width + 40
x = (config.screen_width - width) // 2 # Centrer
height = item_height
# Index global de cette option (après les régions)
global_idx = len(regions_list) + len([opt for opt in options[:options.index(option)] if opt[0] in ['toggle', 'button_inline']])
# Couleur selon sélection
if i == config.selected_filter_option:
if global_idx == config.selected_filter_option:
bg_color = THEME_COLORS["button_selected"]
border_color = THEME_COLORS["border_selected"]
else:
@@ -3512,33 +3714,35 @@ def draw_filter_advanced(screen):
pygame.draw.rect(screen, bg_color, (x, current_y, width, height), border_radius=8)
pygame.draw.rect(screen, border_color, (x, current_y, width, height), 2, border_radius=8)
# Texte
if option_type == 'region':
text = option[2]
text_color = option[3]
else:
text = option[2]
text_color = THEME_COLORS["text"]
text_surface = config.font.render(text, True, text_color)
text_rect = text_surface.get_rect(left=x + 20, centery=current_y + height // 2)
# Texte centré
text_color = THEME_COLORS["text"]
text_rect = text_surface.get_rect(center=(x + width // 2, current_y + height // 2))
screen.blit(text_surface, text_rect)
current_y += height + 10
# Afficher les 3 boutons côte à côte en bas
# Calculer la position pour éviter la barre de contrôles (hauteur estimée ~60-80px)
control_bar_estimated_height = 80
button_width = 200
button_height = 50
current_y += buttons_top_margin
button_y = current_y
button_spacing = 20
total_buttons_width = button_width * 3 + button_spacing * 2
button_start_x = (config.screen_width - total_buttons_width) // 2
button_y = config.screen_height - control_bar_estimated_height - button_height - 20
# Calculer la largeur de chaque bouton en fonction du texte
button_widths = []
for button_id, button_text in buttons:
text_surface = config.font.render(button_text, True, THEME_COLORS["text"])
button_widths.append(text_surface.get_width() + 40) # Padding de 40px
# Largeur totale des boutons
total_buttons_width = sum(button_widths) + button_spacing * (len(buttons) - 1)
button_start_x = (config.screen_width - total_buttons_width) // 2
# Calculer l'index de début des boutons (après toutes les régions et autres options)
button_index_start = len(regions_list) + num_other_options
current_button_x = button_start_x
for i, (button_id, button_text) in enumerate(buttons):
button_index = len(options) + i
button_x = button_start_x + i * (button_width + button_spacing)
button_index = button_index_start + i
button_width = button_widths[i]
# Couleur selon sélection
if button_index == config.selected_filter_option:
@@ -3549,19 +3753,21 @@ def draw_filter_advanced(screen):
border_color = THEME_COLORS["border"]
# Dessiner bouton
pygame.draw.rect(screen, bg_color, (button_x, button_y, button_width, button_height), border_radius=8)
pygame.draw.rect(screen, border_color, (button_x, button_y, button_width, button_height), 2, border_radius=8)
pygame.draw.rect(screen, bg_color, (current_button_x, button_y, button_width, button_height), border_radius=8)
pygame.draw.rect(screen, border_color, (current_button_x, button_y, button_width, button_height), 2, border_radius=8)
# Texte centré
text_surface = config.font.render(button_text, True, THEME_COLORS["text"])
text_rect = text_surface.get_rect(center=(button_x + button_width // 2, button_y + button_height // 2))
text_rect = text_surface.get_rect(center=(current_button_x + button_width // 2, button_y + button_height // 2))
screen.blit(text_surface, text_rect)
current_button_x += button_width + button_spacing
# Info filtre actif (au-dessus des boutons)
if config.game_filter_obj.is_active():
info_text = _("filter_active")
info_surface = config.small_font.render(info_text, True, THEME_COLORS["green"])
info_rect = info_surface.get_rect(center=(config.screen_width // 2, button_y - 30))
info_rect = info_surface.get_rect(center=(config.screen_width // 2, button_y - 20))
screen.blit(info_surface, info_rect)

View File

@@ -339,7 +339,7 @@
"web_restart_error": "Fehler beim Neustart: {0}",
"web_support": "Support",
"web_support_title": "📦 Support-Datei erstellt",
"web_support_message": "Support-Datei erfolgreich erstellt!\\n\\n📁 Inhalt:\\n• Steuerungskonfiguration\\n• Download-Verlauf\\n• RGSX-Einstellungen\\n• Anwendungsprotokolle\\n• Webserver-Protokolle\\n\\n💬 Um Hilfe zu erhalten:\\n1. Trete dem RGSX Discord bei\\n2. Beschreibe dein Problem\\n3. Teile diese ZIP-Datei\\n\\nDownload startet...",
"web_support_message": "Support-Datei erfolgreich erstellt!\n\n📁 Inhalt:\n• Steuerungskonfiguration\n• Download-Verlauf\n• RGSX-Einstellungen\n• Anwendungsprotokolle\n• Webserver-Protokolle\n\n💬 Um Hilfe zu erhalten:\n1. Trete dem RGSX Discord bei\n2. Beschreibe dein Problem\n3. Teile diese ZIP-Datei\n\nDownload startet...",
"web_support_generating": "Support-Datei wird generiert...",
"web_support_download": "Support-Datei herunterladen",
"web_support_error": "Fehler beim Erstellen der Support-Datei: {0}",

View File

@@ -341,7 +341,7 @@
"web_restart_error": "Restart error: {0}",
"web_support": "Support",
"web_support_title": "📦 Support File Generated",
"web_support_message": "Support file created successfully!\\n\\n📁 Contents:\\n• Controls configuration\\n• Download history\\n• RGSX settings\\n• Application logs\\n• Web server logs\\n\\n💬 To get help:\\n1. Join RGSX Discord\\n2. Describe your issue\\n3. Share this ZIP file\\n\\nDownload will start...",
"web_support_message": "Support file created successfully!\n\n📁 Contents:\n• Controls configuration\n• Download history\n• RGSX settings\n• Application logs\n• Web server logs\n\n💬 To get help:\n1. Join RGSX Discord\n2. Describe your issue\n3. Share this ZIP file\n\nDownload will start...",
"web_support_generating": "Generating support file...",
"web_support_download": "Download support file",
"web_support_error": "Error generating support file: {0}",

View File

@@ -341,7 +341,7 @@
"web_restart_error": "Error al reiniciar: {0}",
"web_support": "Soporte",
"web_support_title": "📦 Archivo de soporte generado",
"web_support_message": "¡Archivo de soporte creado con éxito!\\n\\n📁 Contenido:\\n• Configuración de controles\\n• Historial de descargas\\n• Configuración RGSX\\n• Registros de la aplicación\\n• Registros del servidor web\\n\\n💬 Para obtener ayuda:\\n1. Únete al Discord de RGSX\\n2. Describe tu problema\\n3. Comparte este archivo ZIP\\n\\nLa descarga comenzará...",
"web_support_message": "¡Archivo de soporte creado con éxito!\n\n📁 Contenido:\n• Configuración de controles\n• Historial de descargas\n• Configuración RGSX\n• Registros de la aplicación\n• Registros del servidor web\n\n💬 Para obtener ayuda:\n1. Únete al Discord de RGSX\n2. Describe tu problema\n3. Comparte este archivo ZIP\n\nLa descarga comenzará...",
"web_support_generating": "Generando archivo de soporte...",
"web_support_download": "Descargar archivo de soporte",
"web_support_error": "Error al generar el archivo de soporte: {0}",

View File

@@ -341,7 +341,7 @@
"web_restart_error": "Erreur lors du redémarrage : {0}",
"web_support": "Support",
"web_support_title": "📦 Fichier de support généré",
"web_support_message": "Le fichier de support a été créé avec succès !\\n\\n📁 Contenu :\\n• Configuration des contrôles\\n• Historique des téléchargements\\n• Paramètres RGSX\\n• Logs de l'application\\n• Logs du serveur web\\n\\n💬 Pour obtenir de l'aide :\\n1. Rejoignez le Discord RGSX\\n2. Décrivez votre problème\\n3. Partagez ce fichier ZIP\\n\\nLe téléchargement va démarrer...",
"web_support_message": "Le fichier de support a été créé avec succès !\n\n📁 Contenu :\n• Configuration des contrôles\n• Historique des téléchargements\n• Paramètres RGSX\n• Logs de l'application\n• Logs du serveur web\n\n💬 Pour obtenir de l'aide :\n1. Rejoignez le Discord RGSX\n2. Décrivez votre problème\n3. Partagez ce fichier ZIP\n\nLe téléchargement va démarrer...",
"web_support_generating": "Génération du fichier de support...",
"web_support_download": "Télécharger le fichier de support",
"web_support_error": "Erreur lors de la génération du fichier de support : {0}",

View File

@@ -338,7 +338,7 @@
"web_restart_error": "Errore durante il riavvio: {0}",
"web_support": "Supporto",
"web_support_title": "📦 File di supporto generato",
"web_support_message": "File di supporto creato con successo!\\n\\n📁 Contenuto:\\n• Configurazione controlli\\n• Cronologia download\\n• Impostazioni RGSX\\n• Log dell'applicazione\\n• Log del server web\\n\\n💬 Per ottenere aiuto:\\n1. Unisciti al Discord RGSX\\n2. Descrivi il tuo problema\\n3. Condividi questo file ZIP\\n\\nIl download inizierà...",
"web_support_message": "File di supporto creato con successo!\n\n📁 Contenuto:\n• Configurazione controlli\n• Cronologia download\n• Impostazioni RGSX\n• Log dell'applicazione\n• Log del server web\n\n💬 Per ottenere aiuto:\n1. Unisciti al Discord RGSX\n2. Descrivi il tuo problema\n3. Condividi questo file ZIP\n\nIl download inizierà...",
"web_support_generating": "Generazione file di supporto...",
"web_support_download": "Scarica file di supporto",
"web_support_error": "Errore nella generazione del file di supporto: {0}",

View File

@@ -340,7 +340,7 @@
"web_restart_error": "Erro ao reiniciar: {0}",
"web_support": "Suporte",
"web_support_title": "📦 Arquivo de suporte gerado",
"web_support_message": "Arquivo de suporte criado com sucesso!\\n\\n📁 Conteúdo:\\n• Configuração de controles\\n• Histórico de downloads\\n• Configurações RGSX\\n• Logs da aplicação\\n• Logs do servidor web\\n\\n💬 Para obter ajuda:\\n1. Entre no Discord RGSX\\n2. Descreva seu problema\\n3. Compartilhe este arquivo ZIP\\n\\nO download vai começar...",
"web_support_message": "Arquivo de suporte criado com sucesso!\n\n📁 Conteúdo:\n• Configuração de controles\n• Histórico de downloads\n• Configurações RGSX\n• Logs da aplicação\n• Logs do servidor web\n\n💬 Para obter ajuda:\n1. Entre no Discord RGSX\n2. Descreva seu problema\n3. Compartilhe este arquivo ZIP\n\nO download vai começar...",
"web_support_generating": "Gerando arquivo de suporte...",
"web_support_download": "Baixar arquivo de suporte",
"web_support_error": "Erro ao gerar arquivo de suporte: {0}",

View File

@@ -246,11 +246,11 @@ def get_translation(key, default=None):
return key
# Fonction pour normaliser les tailles de fichier
def normalize_size(size_str):
def normalize_size(size_str, lang='en'):
"""
Normalise une taille de fichier dans différents formats (Ko, KiB, Mo, MiB, Go, GiB)
en un format uniforme (Mo ou Go).
Exemples: "150 Mo" -> "150 Mo", "1.5 Go" -> "1.5 Go", "500 Ko" -> "0.5 Mo", "2 GiB" -> "2.15 Go"
en un format uniforme selon la langue (MB/GB pour anglais, Mo/Go pour français).
Exemples: "150 Mo" -> "150 MB" (en), "1.5 Go" -> "1.5 GB" (en), "500 Ko" -> "0.5 MB"
"""
if not size_str:
return None
@@ -282,16 +282,24 @@ def normalize_size(size_str):
elif unit in ['gio', 'gib']:
value = value * 1024 # GiB en Mo
# Afficher en Go si > 1024 Mo, sinon en Mo
if value >= 1024:
return f"{value / 1024:.2f} Go".rstrip('0').rstrip('.')
# Déterminer les unités selon la langue
if lang == 'fr':
mb_unit = 'Mo'
gb_unit = 'Go'
else:
# Arrondir à 1 décimale pour Mo
mb_unit = 'MB'
gb_unit = 'GB'
# Afficher en GB/Go si > 1024 Mo, sinon en MB/Mo
if value >= 1024:
return f"{value / 1024:.2f} {gb_unit}".replace('.00 ', ' ').rstrip('0').rstrip('.')
else:
# Arrondir à 1 décimale pour MB/Mo
rounded = round(value, 1)
if rounded == int(rounded):
return f"{int(rounded)} Mo"
return f"{int(rounded)} {mb_unit}"
else:
return f"{rounded} Mo".rstrip('0').rstrip('.')
return f"{rounded} {mb_unit}".rstrip('0').rstrip('.')
except (ValueError, TypeError):
return size_str # Retourner original si conversion échoue
@@ -472,6 +480,20 @@ class RGSXHandler(BaseHTTPRequestHandler):
"""Répond avec un 404 générique."""
self._set_headers('text/plain; charset=utf-8', status=404)
self.wfile.write(b'Not found')
def _get_language_from_cookies(self):
"""Récupère la langue depuis les cookies ou retourne 'en' par défaut"""
cookie_header = self.headers.get('Cookie', '')
if cookie_header:
# Parser les cookies
cookies = {}
for cookie in cookie_header.split(';'):
cookie = cookie.strip()
if '=' in cookie:
key, value = cookie.split('=', 1)
cookies[key] = value
return cookies.get('language', 'en')
return 'en'
def _asset_version(self, relative_path: str) -> str:
"""Retourne un identifiant de version basé sur la date de modification du fichier statique."""
@@ -681,7 +703,7 @@ class RGSXHandler(BaseHTTPRequestHandler):
'game_name': game_name,
'platform': platform_name,
'url': game[1] if len(game) > 1 and isinstance(game, (list, tuple)) else None,
'size': normalize_size(game[2] if len(game) > 2 and isinstance(game, (list, tuple)) else None)
'size': normalize_size(game[2] if len(game) > 2 and isinstance(game, (list, tuple)) else None, self._get_language_from_cookies())
})
except Exception as e:
logger.debug(f"Erreur lors de la recherche dans {platform_name}: {e}")
@@ -722,12 +744,15 @@ class RGSXHandler(BaseHTTPRequestHandler):
platform_name = path.split('/api/games/')[-1]
platform_name = urllib.parse.unquote(platform_name)
# Récupérer la langue depuis les cookies ou utiliser 'en' par défaut
lang = self._get_language_from_cookies()
games, _, games_last_modified = get_cached_games(platform_name)
games_formatted = [
{
'name': g[0],
'url': g[1] if len(g) > 1 else None,
'size': normalize_size(g[2] if len(g) > 2 else None)
'size': normalize_size(g[2] if len(g) > 2 else None, lang)
}
for g in games
]

View File

@@ -473,3 +473,70 @@ header p { opacity: 0.9; font-size: 1.1em; }
padding: 3px 10px;
}
}
/* Modal Support */
.support-modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.7);
display: flex;
justify-content: center;
align-items: center;
z-index: 10000;
animation: fadeIn 0.2s ease-out;
}
.support-modal-content {
background: #2c2c2c;
color: #ffffff;
padding: 30px;
border-radius: 12px;
max-width: 600px;
max-height: 80vh;
overflow-y: auto;
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
position: relative;
}
.support-modal h2 {
margin: 0 0 20px 0;
color: #4CAF50;
font-size: 24px;
}
.support-modal-message {
white-space: pre-wrap;
line-height: 1.6;
margin-bottom: 25px;
color: #e0e0e0;
}
.support-modal button {
background: #4CAF50;
color: white;
border: none;
padding: 12px 30px;
border-radius: 6px;
cursor: pointer;
font-size: 16px;
font-weight: bold;
width: 100%;
transition: background 0.2s;
}
.support-modal button:hover {
background: #45a049;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes fadeOut {
from { opacity: 1; }
to { opacity: 0; }
}

View File

@@ -109,6 +109,53 @@
document.head.appendChild(style);
}
// Modal pour afficher les messages support avec formatage
function showSupportModal(title, message) {
// Remplacer les \n littéraux par de vrais retours à la ligne
message = message.replace(/\\n/g, '\n');
// Créer la modal
const modal = document.createElement('div');
modal.className = 'support-modal';
const modalContent = document.createElement('div');
modalContent.className = 'support-modal-content';
// Titre
const titleElement = document.createElement('h2');
titleElement.textContent = title;
// Message avec retours à la ligne préservés
const messageElement = document.createElement('div');
messageElement.className = 'support-modal-message';
messageElement.textContent = message;
// Bouton OK
const okButton = document.createElement('button');
okButton.textContent = 'OK';
okButton.onclick = () => {
modal.style.animation = 'fadeOut 0.2s ease-in';
setTimeout(() => modal.remove(), 200);
};
// Assembler la modal
modalContent.appendChild(titleElement);
modalContent.appendChild(messageElement);
modalContent.appendChild(okButton);
modal.appendChild(modalContent);
// Ajouter au DOM
document.body.appendChild(modal);
// Fermer en cliquant sur le fond
modal.onclick = (e) => {
if (e.target === modal) {
modal.style.animation = 'fadeOut 0.2s ease-in';
setTimeout(() => modal.remove(), 200);
}
};
}
// Charger les traductions au démarrage
async function loadTranslations() {
try {
@@ -1036,13 +1083,24 @@
const getSizeInMo = (sizeElem) => {
if (!sizeElem) return 0;
const text = sizeElem.textContent;
// Les tailles sont maintenant normalisées: "100 Mo" ou "2.5 Go"
const match = text.match(/([0-9.]+)\\s*(Mo|Go)/i);
// Support des formats: "100 Mo", "2.5 Go" (français) et "100 MB", "2.5 GB" (anglais)
// Plus Ko/KB, o/B, To/TB
const match = text.match(/([0-9.]+)\s*(o|B|Ko|KB|Mo|MB|Go|GB|To|TB)/i);
if (!match) return 0;
let size = parseFloat(match[1]);
// Convertir Go en Mo pour comparaison
if (match[2].toUpperCase() === 'GO') {
size *= 1024;
const unit = match[2].toUpperCase();
// Convertir tout en Mo
if (unit === 'O' || unit === 'B') {
size /= (1024 * 1024); // octets/bytes vers Mo
} else if (unit === 'KO' || unit === 'KB') {
size /= 1024; // Ko vers Mo
} else if (unit === 'MO' || unit === 'MB') {
// Déjà en Mo
} else if (unit === 'GO' || unit === 'GB') {
size *= 1024; // Go vers Mo
} else if (unit === 'TO' || unit === 'TB') {
size *= 1024 * 1024; // To vers Mo
}
return size;
};
@@ -2075,7 +2133,7 @@
hide_non_release: document.getElementById('hide-non-release')?.checked || savedHideNonRelease,
one_rom_per_game: document.getElementById('one-rom-per-game')?.checked || savedOneRomPerGame,
regex_mode: document.getElementById('regex-mode')?.checked || savedRegexMode,
region_priority: regionPriority
region_priority: regionPriorityOrder
}
};
@@ -2175,7 +2233,7 @@
}
// Générer un fichier ZIP de support
async function generateSupportZip() {
async function generateSupportZip(event) {
try {
// Afficher un message de chargement
const loadingMsg = t('web_support_generating');
@@ -2218,8 +2276,8 @@
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
// Afficher le message d'instructions
alert(t('web_support_title') + '\\n\\n' + t('web_support_message'));
// Afficher le message d'instructions dans une modal
showSupportModal(t('web_support_title'), t('web_support_message'));
// Restaurer le bouton
if (originalButton) {

View File

@@ -1,3 +1,3 @@
{
"version": "2.3.2.7"
"version": "2.3.2.8"
}