Add internationalization support and Russian translations

- Introduced a new i18n module for handling translations.
- Added Russian translations for various UI prompts, error messages, and confirmations in the `ru.ini` localization file.
- Updated multiple UI components to utilize the new translation functions, ensuring messages are displayed in the user's selected language.
- Enhanced user input validation messages to be translatable.
- Refactored dialog and input handling functions to support dynamic text translation.
This commit is contained in:
pdxlocations
2026-01-22 22:26:40 -08:00
parent 6527e7cf89
commit 26b8e3f1ba
10 changed files with 490 additions and 90 deletions
+77 -3
View File
@@ -13,6 +13,79 @@ Reset Node DB, "Reset Node DB", ""
Shutdown, "Shutdown", ""
Factory Reset, "Factory Reset", ""
Exit, "Exit", ""
Yes, "Yes", ""
No, "No", ""
Cancel, "Cancel", ""
[ui]
save_changes, "Save Changes", ""
dialog.invalid_input, "Invalid Input", ""
prompt.enter_new_value, "Enter new value: ", ""
error.value_empty, "Value cannot be empty.", ""
error.value_exact_length, "Value must be exactly {length} characters long.", ""
error.value_min_length, "Value must be at least {length} characters long.", ""
error.value_max_length, "Value must be no more than {length} characters long.", ""
error.digits_only, "Only numeric digits (0-9) allowed.", ""
error.number_range, "Enter a number between {min_value} and {max_value}.", ""
error.float_invalid, "Must be a valid floating point number.", ""
prompt.edit_admin_keys, "Edit up to 3 Admin Keys:", ""
label.admin_key, "Admin Key", ""
error.admin_key_invalid, "Error: Each key must be valid Base64 and 32 bytes long!", ""
prompt.edit_values, "Edit up to 3 Values:", ""
label.value, "Value", ""
prompt.enter_ip, "Enter an IP address (xxx.xxx.xxx.xxx):", ""
label.current, "Current", ""
label.new_value, "New value", ""
label.editing, "Editing {label}", ""
label.current_value, "Current Value:", ""
error.ip_invalid, "Invalid IP address. Try again.", ""
prompt.select_foreground_color, "Select Foreground Color for {label}", ""
prompt.select_background_color, "Select Background Color for {label}", ""
prompt.select_value, "Select {label}", ""
confirm.save_before_exit, "You have unsaved changes. Save before exiting?", ""
prompt.config_filename, "Enter a filename for the config file", ""
confirm.overwrite_file, "{filename} already exists. Overwrite?", ""
dialog.config_saved_title, "Config File Saved:", ""
dialog.no_config_files, " No config files found. Export a config first.", ""
prompt.choose_config_file, "Choose a config file", ""
confirm.load_config_file, "Are you sure you want to load {filename}?", ""
prompt.config_url_current, "Config URL is currently: {value}", ""
confirm.load_config_url, "Are you sure you want to load this config?", ""
confirm.reboot, "Are you sure you want to Reboot?", ""
confirm.reset_node_db, "Are you sure you want to Reset Node DB?", ""
confirm.shutdown, "Are you sure you want to Shutdown?", ""
confirm.factory_reset, "Are you sure you want to Factory Reset?", ""
confirm.save_before_exit_section, "You have unsaved changes in {section}. Save before exiting?", ""
prompt.select_region, "Select your region:", ""
dialog.slow_down_title, "Slow down", ""
dialog.slow_down_body, "Please wait 2 seconds between messages.", ""
dialog.node_details_title, "📡 Node Details: {name}", ""
dialog.traceroute_not_sent_title, "Traceroute Not Sent", ""
dialog.traceroute_not_sent_body, "Please wait {seconds} seconds before sending another traceroute.", ""
dialog.traceroute_sent_title, "Traceroute Sent To: {name}", ""
dialog.traceroute_sent_body, "Results will appear in messages window.", ""
dialog.help_title, "Help - Shortcut Keys", ""
help.scroll, "Up/Down = Scroll", ""
help.switch_window, "Left/Right = Switch window", ""
help.jump_windows, "F1/F2/F3 = Jump to Channel/Messages/Nodes", ""
help.enter, "ENTER = Send / Select", ""
help.settings, "` or F12 = Settings", ""
help.quit, "ESC = Quit", ""
help.packet_log, "Ctrl+P = Toggle Packet Log", ""
help.traceroute, "Ctrl+T or F4 = Traceroute", ""
help.node_info, "F5 = Full node info", ""
help.archive_chat, "Ctrl+D = Archive chat / remove node", ""
help.favorite, "Ctrl+F = Favorite", ""
help.ignore, "Ctrl+G = Ignore", ""
help.search, "Ctrl+/ = Search", ""
help.help, "Ctrl+K = Help", ""
help.no_help, "No help available.", ""
confirm.remove_from_nodedb, "Remove {name} from nodedb?", ""
confirm.set_favorite, "Set {name} as Favorite?", ""
confirm.remove_favorite, "Remove {name} from Favorites?", ""
confirm.set_ignored, "Set {name} as Ignored?", ""
confirm.remove_ignored, "Remove {name} from Ignored?", ""
confirm.region_unset, "Your region is UNSET. Set it now?", ""
[User Settings]
user, "User"
@@ -33,10 +106,10 @@ message_prefix, "Message prefix", ""
sent_message_prefix, "Sent message prefix", ""
notification_symbol, "Notification symbol", ""
notification_sound, "Notification sound", ""
ack_implicit_str, "Ack (implicit)", ""
ack_str, "Ack", ""
ack_implicit_str, "ACK (implicit)", ""
ack_str, "ACK", ""
nak_str, "NAK", ""
ack_unknown_str, "Ack (unknown)", ""
ack_unknown_str, "ACK (unknown)", ""
node_sort, "Node sort", ""
theme, "Theme", ""
COLOR_CONFIG_DARK, "Theme colors (dark)", ""
@@ -320,6 +393,7 @@ power_screen_enabled, "Power screen enabled", "Show the power telemetry data on
health_measurement_enabled, "Health telemetry interval", "This option is used to configure the interval in seconds that should be used to send health data from an attached supported sensor over the mesh network in seconds."
health_update_interval, "Health telemetry enabled", "This option is used to enable/disable the sending of health data from an attached supported sensor over the mesh network."
health_screen_enabled, "Health screen enabled", "Show the health telemetry data on the device display."
device_telemetry_enabled, "Device telemetry enabled", "Enable the Device Telemetry"
[module.canned_message]
title, "Canned Message"
+74
View File
@@ -13,6 +13,79 @@ Reset Node DB, "Сбросить БД узлов", ""
Shutdown, "Выключить", ""
Factory Reset, "Сброс до заводских", ""
Exit, "Выход", ""
Yes, "Да", ""
No, "Нет", ""
Cancel, "Отмена", ""
[ui]
save_changes, "Сохранить изменения", ""
dialog.invalid_input, "Некорректный ввод", ""
prompt.enter_new_value, "Введите новое значение: ", ""
error.value_empty, "Значение не может быть пустым.", ""
error.value_exact_length, "Значение должно быть длиной ровно {length} символов.", ""
error.value_min_length, "Значение должно быть не короче {length} символов.", ""
error.value_max_length, "Значение должно быть не длиннее {length} символов.", ""
error.digits_only, "Разрешены только цифры (0-9).", ""
error.number_range, "Введите число между {min_value} и {max_value}.", ""
error.float_invalid, "Введите корректное число с плавающей точкой.", ""
prompt.edit_admin_keys, "Редактировать до 3 ключей администратора:", ""
label.admin_key, "Ключ администратора", ""
error.admin_key_invalid, "Ошибка: каждый ключ должен быть Base64 и длиной 32 байта.", ""
prompt.edit_values, "Редактировать до 3 значений:", ""
label.value, "Значение", ""
prompt.enter_ip, "Введите IP-адрес (xxx.xxx.xxx.xxx):", ""
label.current, "Текущее", ""
label.new_value, "Новое значение", ""
label.editing, "Редактирование {label}", ""
label.current_value, "Текущее значение:", ""
error.ip_invalid, "Неверный IP-адрес. Попробуйте еще раз.", ""
prompt.select_foreground_color, "Выберите цвет текста для {label}", ""
prompt.select_background_color, "Выберите цвет фона для {label}", ""
prompt.select_value, "Выберите {label}", ""
confirm.save_before_exit, "Есть несохраненные изменения. Сохранить перед выходом?", ""
prompt.config_filename, "Введите имя файла конфигурации", ""
confirm.overwrite_file, "Файл {filename} уже существует. Перезаписать?", ""
dialog.config_saved_title, "Файл конфигурации сохранен:", ""
dialog.no_config_files, " Нет файлов конфигурации. Сначала экспортируйте конфигурацию.", ""
prompt.choose_config_file, "Выберите файл конфигурации", ""
confirm.load_config_file, "Загрузить файл {filename}?", ""
prompt.config_url_current, "Текущий URL конфигурации: {value}", ""
confirm.load_config_url, "Загрузить эту конфигурацию?", ""
confirm.reboot, "Перезагрузить устройство?", ""
confirm.reset_node_db, "Сбросить БД узлов?", ""
confirm.shutdown, "Выключить устройство?", ""
confirm.factory_reset, "Сбросить до заводских настроек?", ""
confirm.save_before_exit_section, "Есть несохраненные изменения в {section}. Сохранить перед выходом?", ""
prompt.select_region, "Выберите ваш регион:", ""
dialog.slow_down_title, "Подождите", ""
dialog.slow_down_body, "Подождите 2 секунды между сообщениями.", ""
dialog.node_details_title, "📡 Информация об узле: {name}", ""
dialog.traceroute_not_sent_title, "Traceroute не отправлен", ""
dialog.traceroute_not_sent_body, "Подождите {seconds} секунд перед повторной отправкой traceroute.", ""
dialog.traceroute_sent_title, "Traceroute отправлен: {name}", ""
dialog.traceroute_sent_body, "Результаты появятся в окне сообщений.", ""
dialog.help_title, "Справка - горячие клавиши", ""
help.scroll, "Вверх/Вниз = Прокрутка", ""
help.switch_window, "Влево/Вправо = Переключить окно", ""
help.jump_windows, "F1/F2/F3 = Каналы/Сообщения/Узлы", ""
help.enter, "ENTER = Отправить / Выбрать", ""
help.settings, "` или F12 = Настройки", ""
help.quit, "ESC = Выход", ""
help.packet_log, "Ctrl+P = Журнал пакетов", ""
help.traceroute, "Ctrl+T или F4 = Traceroute", ""
help.node_info, "F5 = Полная информация об узле", ""
help.archive_chat, "Ctrl+D = Архив чата / удалить узел", ""
help.favorite, "Ctrl+F = Избранное", ""
help.ignore, "Ctrl+G = Игнорировать", ""
help.search, "Ctrl+/ = Поиск", ""
help.help, "Ctrl+K = Справка", ""
help.no_help, "Нет справки.", ""
confirm.remove_from_nodedb, "Удалить {name} из базы узлов?", ""
confirm.set_favorite, "Добавить {name} в избранное?", ""
confirm.remove_favorite, "Удалить {name} из избранного?", ""
confirm.set_ignored, "Игнорировать {name}?", ""
confirm.remove_ignored, "Убрать {name} из игнорируемых?", ""
confirm.region_unset, "Ваш регион НЕ ЗАДАН. Установить сейчас?", ""
[User Settings]
user, "Пользователь"
@@ -320,6 +393,7 @@ power_screen_enabled, "Power screen enabled", "Show the power telemetry data on
health_measurement_enabled, "Health telemetry interval", "This option is used to configure the interval in seconds that should be used to send health data from an attached supported sensor over the mesh network in seconds."
health_update_interval, "Health telemetry enabled", "This option is used to enable/disable the sending of health data from an attached supported sensor over the mesh network."
health_screen_enabled, "Health screen enabled", "Show the health telemetry data on the device display."
device_telemetry_enabled, "Device telemetry enabled", "Enable the Device Telemetry"
[module.canned_message]
title, "Canned Message"
+6 -1
View File
@@ -7,6 +7,7 @@ import traceback
import contact.ui.default_config as config
from contact.utilities.input_handlers import get_list_input
from contact.utilities.i18n import t
from contact.ui.colors import setup_colors
from contact.ui.splash import draw_splash
from contact.ui.control_ui import set_region, settings_menu
@@ -28,7 +29,11 @@ def main(stdscr: curses.window) -> None:
interface = initialize_interface(args)
if interface.localNode.localConfig.lora.region == 0:
confirmation = get_list_input("Your region is UNSET. Set it now?", "Yes", ["Yes", "No"])
confirmation = get_list_input(
t("ui.confirm.region_unset", default="Your region is UNSET. Set it now?"),
"Yes",
["Yes", "No"],
)
if confirmation == "Yes":
set_region(interface)
interface.close()
+65 -25
View File
@@ -11,6 +11,7 @@ from contact.utilities.utils import parse_protobuf
from contact.ui.colors import get_color
from contact.utilities.db_handler import get_name_from_database, update_node_info_in_db, is_chat_archived
from contact.utilities.input_handlers import get_list_input
from contact.utilities.i18n import t
import contact.ui.default_config as config
import contact.ui.dialog
from contact.ui.nav_utils import move_main_highlight, draw_main_arrows, get_msg_window_lines, wrap_text
@@ -433,7 +434,10 @@ def handle_enter(input_text: str) -> str:
# TODO: This is a hack to prevent sending messages too quickly. Let's get errors from the node.
now = time.monotonic()
if now - ui_state.last_sent_time < 2.5:
contact.ui.dialog.dialog("Slow down", "Please wait 2 seconds between messages.")
contact.ui.dialog.dialog(
t("ui.dialog.slow_down_title", default="Slow down"),
t("ui.dialog.slow_down_body", default="Please wait 2 seconds between messages."),
)
return input_text
# Enter key pressed, send user input as message
send_message(input_text, channel=ui_state.selected_channel)
@@ -525,7 +529,14 @@ def handle_f5_key(stdscr: curses.window) -> None:
message = "\n".join(message_parts)
contact.ui.dialog.dialog(f"📡 Node Details: {node.get('user', {}).get('shortName', 'Unknown')}", message)
contact.ui.dialog.dialog(
t(
"ui.dialog.node_details_title",
default="📡 Node Details: {name}",
name=node.get("user", {}).get("shortName", "Unknown"),
),
message,
)
curses.curs_set(1) # Show cursor again
handle_resize(stdscr, False)
@@ -542,7 +553,12 @@ def handle_ctrl_t(stdscr: curses.window) -> None:
if remaining > 0:
curses.curs_set(0) # Hide cursor
contact.ui.dialog.dialog(
"Traceroute Not Sent", f"Please wait {int(remaining)} seconds before sending another traceroute."
t("ui.dialog.traceroute_not_sent_title", default="Traceroute Not Sent"),
t(
"ui.dialog.traceroute_not_sent_body",
default="Please wait {seconds} seconds before sending another traceroute.",
seconds=int(remaining),
),
)
curses.curs_set(1) # Show cursor again
handle_resize(stdscr, False)
@@ -552,8 +568,12 @@ def handle_ctrl_t(stdscr: curses.window) -> None:
ui_state.last_traceroute_time = now
curses.curs_set(0) # Hide cursor
contact.ui.dialog.dialog(
f"Traceroute Sent To: {get_name_from_database(ui_state.node_list[ui_state.selected_node])}",
"Results will appear in messages window.",
t(
"ui.dialog.traceroute_sent_title",
default="Traceroute Sent To: {name}",
name=get_name_from_database(ui_state.node_list[ui_state.selected_node]),
),
t("ui.dialog.traceroute_sent_body", default="Results will appear in messages window."),
)
curses.curs_set(1) # Show cursor again
handle_resize(stdscr, False)
@@ -601,23 +621,23 @@ def handle_ctrl_k(stdscr: curses.window) -> None:
curses.curs_set(0)
cmds = [
"↑/↓ = Scroll",
"←/→ = Switch window",
"F1/F2/F3 = Jump to Channel/Messages/Nodes",
"ENTER = Send / Select",
"` or F12 = Settings",
"ESC = Quit",
"Ctrl+P = Toggle Packet Log",
"Ctrl+T or F4 = Traceroute",
"F5 = Full node info",
"Ctrl+D = Archive chat / remove node",
"Ctrl+F = Favorite",
"Ctrl+G = Ignore",
"Ctrl+/ = Search",
"Ctrl+K = Help",
t("ui.help.scroll", default="Up/Down = Scroll"),
t("ui.help.switch_window", default="Left/Right = Switch window"),
t("ui.help.jump_windows", default="F1/F2/F3 = Jump to Channel/Messages/Nodes"),
t("ui.help.enter", default="ENTER = Send / Select"),
t("ui.help.settings", default="` or F12 = Settings"),
t("ui.help.quit", default="ESC = Quit"),
t("ui.help.packet_log", default="Ctrl+P = Toggle Packet Log"),
t("ui.help.traceroute", default="Ctrl+T or F4 = Traceroute"),
t("ui.help.node_info", default="F5 = Full node info"),
t("ui.help.archive_chat", default="Ctrl+D = Archive chat / remove node"),
t("ui.help.favorite", default="Ctrl+F = Favorite"),
t("ui.help.ignore", default="Ctrl+G = Ignore"),
t("ui.help.search", default="Ctrl+/ = Search"),
t("ui.help.help", default="Ctrl+K = Help"),
]
contact.ui.dialog.dialog("Help Shortcut Keys", "\n".join(cmds))
contact.ui.dialog.dialog(t("ui.dialog.help_title", default="Help - Shortcut Keys"), "\n".join(cmds))
curses.curs_set(1)
handle_resize(stdscr, False)
@@ -642,7 +662,11 @@ def handle_ctrl_d() -> None:
if ui_state.current_window == 2:
curses.curs_set(0)
confirmation = get_list_input(
f"Remove {get_name_from_database(ui_state.node_list[ui_state.selected_node])} from nodedb?",
t(
"ui.confirm.remove_from_nodedb",
default="Remove {name} from nodedb?",
name=get_name_from_database(ui_state.node_list[ui_state.selected_node]),
),
"No",
["Yes", "No"],
)
@@ -680,7 +704,11 @@ def handle_ctrl_f(stdscr: curses.window) -> None:
if "isFavorite" not in selectedNode or selectedNode["isFavorite"] == False:
confirmation = get_list_input(
f"Set {get_name_from_database(ui_state.node_list[ui_state.selected_node])} as Favorite?",
t(
"ui.confirm.set_favorite",
default="Set {name} as Favorite?",
name=get_name_from_database(ui_state.node_list[ui_state.selected_node]),
),
None,
["Yes", "No"],
)
@@ -693,7 +721,11 @@ def handle_ctrl_f(stdscr: curses.window) -> None:
else:
confirmation = get_list_input(
f"Remove {get_name_from_database(ui_state.node_list[ui_state.selected_node])} from Favorites?",
t(
"ui.confirm.remove_favorite",
default="Remove {name} from Favorites?",
name=get_name_from_database(ui_state.node_list[ui_state.selected_node]),
),
None,
["Yes", "No"],
)
@@ -716,7 +748,11 @@ def handle_ctlr_g(stdscr: curses.window) -> None:
if "isIgnored" not in selectedNode or selectedNode["isIgnored"] == False:
confirmation = get_list_input(
f"Set {get_name_from_database(ui_state.node_list[ui_state.selected_node])} as Ignored?",
t(
"ui.confirm.set_ignored",
default="Set {name} as Ignored?",
name=get_name_from_database(ui_state.node_list[ui_state.selected_node]),
),
"No",
["Yes", "No"],
)
@@ -725,7 +761,11 @@ def handle_ctlr_g(stdscr: curses.window) -> None:
interface_state.interface.nodesByNum[ui_state.node_list[ui_state.selected_node]]["isIgnored"] = True
else:
confirmation = get_list_input(
f"Remove {get_name_from_database(ui_state.node_list[ui_state.selected_node])} from Ignored?",
t(
"ui.confirm.remove_ignored",
default="Remove {name} from Ignored?",
name=get_name_from_database(ui_state.node_list[ui_state.selected_node]),
),
"No",
["Yes", "No"],
)
+71 -17
View File
@@ -16,6 +16,7 @@ from contact.utilities.input_handlers import (
get_list_input,
get_admin_key_input,
)
from contact.utilities.i18n import t
from contact.ui.colors import get_color
from contact.ui.dialog import dialog
from contact.ui.menus import generate_menu_from_protobuf
@@ -134,10 +135,11 @@ def display_menu() -> tuple[object, object]:
if menu_state.show_save_option:
save_position = menu_height - 2
save_label = t("ui.save_changes", default=save_option)
menu_win.addstr(
save_position,
(w - len(save_option)) // 2,
save_option,
(w - len(save_label)) // 2,
save_label,
get_color("settings_save", reverse=(menu_state.selected_index == len(menu_state.current_menu))),
)
@@ -322,7 +324,11 @@ def settings_menu(stdscr: object, interface: object) -> None:
elif selected_option == "Export Config File":
filename = get_text_input("Enter a filename for the config file", None, None)
filename = get_text_input(
t("ui.prompt.config_filename", default="Enter a filename for the config file"),
None,
None,
)
if not filename:
logging.info("Export aborted: No filename provided.")
menu_state.start_index.pop()
@@ -335,7 +341,15 @@ def settings_menu(stdscr: object, interface: object) -> None:
yaml_file_path = os.path.join(config_folder, filename)
if os.path.exists(yaml_file_path):
overwrite = get_list_input(f"{filename} already exists. Overwrite?", None, ["Yes", "No"])
overwrite = get_list_input(
t(
"ui.confirm.overwrite_file",
default="{filename} already exists. Overwrite?",
filename=filename,
),
None,
["Yes", "No"],
)
if overwrite == "No":
logging.info("Export cancelled: User chose not to overwrite.")
menu_state.start_index.pop()
@@ -344,7 +358,7 @@ def settings_menu(stdscr: object, interface: object) -> None:
with open(yaml_file_path, "w", encoding="utf-8") as file:
file.write(config_text)
logging.info(f"Config file saved to {yaml_file_path}")
dialog("Config File Saved:", yaml_file_path)
dialog(t("ui.dialog.config_saved_title", default="Config File Saved:"), yaml_file_path)
menu_state.need_redraw = True
menu_state.start_index.pop()
continue
@@ -361,7 +375,7 @@ def settings_menu(stdscr: object, interface: object) -> None:
# Check if folder exists and is not empty
if not os.path.exists(config_folder) or not any(os.listdir(config_folder)):
dialog("", " No config files found. Export a config first.")
dialog("", t("ui.dialog.no_config_files", default=" No config files found. Export a config first."))
menu_state.need_redraw = True
continue # Return to menu
@@ -369,14 +383,24 @@ def settings_menu(stdscr: object, interface: object) -> None:
# Ensure file_list is not empty before proceeding
if not file_list:
dialog("", " No config files found. Export a config first.")
dialog("", t("ui.dialog.no_config_files", default=" No config files found. Export a config first."))
menu_state.need_redraw = True
continue
filename = get_list_input("Choose a config file", None, file_list)
filename = get_list_input(
t("ui.prompt.choose_config_file", default="Choose a config file"), None, file_list
)
if filename:
file_path = os.path.join(config_folder, filename)
overwrite = get_list_input(f"Are you sure you want to load {filename}?", None, ["Yes", "No"])
overwrite = get_list_input(
t(
"ui.confirm.load_config_file",
default="Are you sure you want to load {filename}?",
filename=filename,
),
None,
["Yes", "No"],
)
if overwrite == "Yes":
config_import(interface, file_path)
menu_state.start_index.pop()
@@ -384,10 +408,22 @@ def settings_menu(stdscr: object, interface: object) -> None:
elif selected_option == "Config URL":
current_value = interface.localNode.getURL()
new_value = get_text_input(f"Config URL is currently: {current_value}", None, str)
new_value = get_text_input(
t(
"ui.prompt.config_url_current",
default="Config URL is currently: {value}",
value=current_value,
),
None,
str,
)
if new_value is not None:
current_value = new_value
overwrite = get_list_input(f"Are you sure you want to load this config?", None, ["Yes", "No"])
overwrite = get_list_input(
t("ui.confirm.load_config_url", default="Are you sure you want to load this config?"),
None,
["Yes", "No"],
)
if overwrite == "Yes":
interface.localNode.setURL(new_value)
logging.info(f"New Config URL sent to node")
@@ -395,7 +431,9 @@ def settings_menu(stdscr: object, interface: object) -> None:
continue
elif selected_option == "Reboot":
confirmation = get_list_input("Are you sure you want to Reboot?", None, ["Yes", "No"])
confirmation = get_list_input(
t("ui.confirm.reboot", default="Are you sure you want to Reboot?"), None, ["Yes", "No"]
)
if confirmation == "Yes":
interface.localNode.reboot()
logging.info(f"Node Reboot Requested by menu")
@@ -403,7 +441,11 @@ def settings_menu(stdscr: object, interface: object) -> None:
continue
elif selected_option == "Reset Node DB":
confirmation = get_list_input("Are you sure you want to Reset Node DB?", None, ["Yes", "No"])
confirmation = get_list_input(
t("ui.confirm.reset_node_db", default="Are you sure you want to Reset Node DB?"),
None,
["Yes", "No"],
)
if confirmation == "Yes":
interface.localNode.resetNodeDb()
logging.info(f"Node DB Reset Requested by menu")
@@ -411,7 +453,9 @@ def settings_menu(stdscr: object, interface: object) -> None:
continue
elif selected_option == "Shutdown":
confirmation = get_list_input("Are you sure you want to Shutdown?", None, ["Yes", "No"])
confirmation = get_list_input(
t("ui.confirm.shutdown", default="Are you sure you want to Shutdown?"), None, ["Yes", "No"]
)
if confirmation == "Yes":
interface.localNode.shutdown()
logging.info(f"Node Shutdown Requested by menu")
@@ -419,7 +463,11 @@ def settings_menu(stdscr: object, interface: object) -> None:
continue
elif selected_option == "Factory Reset":
confirmation = get_list_input("Are you sure you want to Factory Reset?", None, ["Yes", "No"])
confirmation = get_list_input(
t("ui.confirm.factory_reset", default="Are you sure you want to Factory Reset?"),
None,
["Yes", "No"],
)
if confirmation == "Yes":
interface.localNode.factoryReset()
logging.info(f"Factory Reset Requested by menu")
@@ -570,7 +618,11 @@ def settings_menu(stdscr: object, interface: object) -> None:
current_section = menu_state.menu_path[-1]
save_prompt = get_list_input(
f"You have unsaved changes in {current_section}. Save before exiting?",
t(
"ui.confirm.save_before_exit_section",
default="You have unsaved changes in {section}. Save before exiting?",
section=current_section,
),
None,
["Yes", "No", "Cancel"],
mandatory=True,
@@ -637,7 +689,9 @@ def set_region(interface: object) -> None:
regions = list(region_name_to_number.keys())
new_region_name = get_list_input("Select your region:", "UNSET", regions)
new_region_name = get_list_input(
t("ui.prompt.select_region", default="Select your region:"), "UNSET", regions
)
# Convert region name to corresponding enum number
new_region_number = region_name_to_number.get(new_region_name, 0) # Default to 0 if not found
+4
View File
@@ -1,10 +1,14 @@
import curses
from contact.utilities.i18n import t_text
from contact.ui.colors import get_color
from contact.ui.nav_utils import draw_main_arrows
from contact.utilities.singleton import menu_state, ui_state
def dialog(title: str, message: str) -> None:
title = t_text(title)
message = t_text(message)
"""Display a dialog with a title and message."""
previous_window = ui_state.current_window
+18 -6
View File
@@ -3,6 +3,7 @@ import re
from unicodedata import east_asian_width
from contact.ui.colors import get_color
from contact.utilities.i18n import t
from contact.utilities.control_utils import transform_menu_path
from typing import Any, Optional, List, Dict
from contact.utilities.singleton import interface_state, ui_state
@@ -24,6 +25,10 @@ WrappedLine = List[Segment]
sensitive_settings = ["Reboot", "Reset Node DB", "Shutdown", "Factory Reset"]
save_option = "Save Changes"
def get_save_option_label() -> str:
return t("ui.save_changes", default=save_option)
MIN_HEIGHT_FOR_HELP = 20
@@ -54,6 +59,9 @@ def move_highlight(
if "max_help_lines" in kwargs:
max_help_lines = kwargs["max_help_lines"]
if not options:
return
if old_idx == new_idx: # No-op
return
@@ -74,8 +82,11 @@ def move_highlight(
# Clear old selection
if show_save_option and old_idx == max_index:
win_h, win_w = menu_win.getmaxyx()
menu_win.chgat(win_h - 2, (win_w - len(save_option)) // 2, len(save_option), get_color("settings_save"))
else:
save_label = get_save_option_label()
menu_win.chgat(
win_h - 2, (win_w - len(save_label)) // 2, len(save_label), get_color("settings_save")
)
elif 0 <= old_idx < len(options):
menu_pad.chgat(
old_idx,
0,
@@ -90,13 +101,14 @@ def move_highlight(
# Highlight new selection
if show_save_option and new_idx == max_index:
win_h, win_w = menu_win.getmaxyx()
save_label = get_save_option_label()
menu_win.chgat(
win_h - 2,
(win_w - len(save_option)) // 2,
len(save_option),
(win_w - len(save_label)) // 2,
len(save_label),
get_color("settings_save", reverse=True),
)
else:
elif 0 <= new_idx < len(options):
menu_pad.chgat(
new_idx,
0,
@@ -233,7 +245,7 @@ def get_wrapped_help_text(
"""Fetches and formats help text for display, ensuring it fits within the allowed lines."""
full_help_key = ".".join(transformed_path + [selected_option]) if selected_option else None
help_content = help_text.get(full_help_key, "No help available.")
help_content = help_text.get(full_help_key, t("ui.help.no_help", default="No help available."))
wrap_width = max(width - 6, 10) # Ensure a valid wrapping width
+56 -16
View File
@@ -8,6 +8,7 @@ import contact.ui.default_config as config
from contact.ui.nav_utils import move_highlight, draw_arrows, update_help_window
from contact.utilities.control_utils import parse_ini_file
from contact.utilities.input_handlers import get_list_input
from contact.utilities.i18n import t
from contact.utilities.singleton import menu_state
@@ -90,8 +91,24 @@ def edit_color_pair(key: str, display_label: str, current_value: List[str]) -> L
Allows the user to select a foreground and background color for a key.
"""
color_list = [" "] + list(COLOR_MAP.keys())
fg_color = get_list_input(f"Select Foreground Color for {display_label}", current_value[0], color_list)
bg_color = get_list_input(f"Select Background Color for {display_label}", current_value[1], color_list)
fg_color = get_list_input(
t(
"ui.prompt.select_foreground_color",
default="Select Foreground Color for {label}",
label=display_label,
),
current_value[0],
color_list,
)
bg_color = get_list_input(
t(
"ui.prompt.select_background_color",
default="Select Background Color for {label}",
label=display_label,
),
current_value[1],
color_list,
)
return [fg_color, bg_color]
@@ -111,8 +128,13 @@ def edit_value(key: str, display_label: str, current_value: str) -> str:
edit_win.border()
# Display instructions
edit_win.addstr(1, 2, f"Editing {display_label}", get_color("settings_default", bold=True))
edit_win.addstr(3, 2, "Current Value:", get_color("settings_default"))
edit_win.addstr(
1,
2,
t("ui.label.editing", default="Editing {label}", label=display_label),
get_color("settings_default", bold=True),
)
edit_win.addstr(3, 2, t("ui.label.current_value", default="Current Value:"), get_color("settings_default"))
wrap_width = w - 4 # Account for border and padding
wrapped_lines = [current_value[i : i + wrap_width] for i in range(0, len(current_value), wrap_width)]
@@ -128,28 +150,36 @@ def edit_value(key: str, display_label: str, current_value: str) -> str:
theme_options = [
k.split("_", 2)[2].lower() for k in config.loaded_config.keys() if k.startswith("COLOR_CONFIG")
]
return get_list_input(f"Select {display_label}", current_value, theme_options)
return get_list_input(
t("ui.prompt.select_value", default="Select {label}", label=display_label),
current_value,
theme_options,
)
elif key == "language":
language_options = config.get_localisation_options()
if not language_options:
return current_value
return get_list_input(f"Select {display_label}", current_value, language_options)
return get_list_input(
t("ui.prompt.select_value", default="Select {label}", label=display_label),
current_value,
language_options,
)
elif key == "node_sort":
sort_options = ["lastHeard", "name", "hops"]
return get_list_input(f"{display_label}", current_value, sort_options)
return get_list_input(display_label, current_value, sort_options)
elif key == "notification_sound":
sound_options = ["True", "False"]
return get_list_input(f"{display_label}", current_value, sound_options)
return get_list_input(display_label, current_value, sound_options)
elif key == "single_pane_mode":
sound_options = ["True", "False"]
return get_list_input(f"{display_label}", current_value, sound_options)
return get_list_input(display_label, current_value, sound_options)
# Standard Input Mode (Scrollable)
edit_win.addstr(7, 2, "New Value: ", get_color("settings_default"))
edit_win.addstr(7, 2, t("ui.label.new_value", default="New Value: "), get_color("settings_default"))
curses.curs_set(1)
scroll_offset = 0 # Determines which part of the text is visible
@@ -170,11 +200,20 @@ def edit_value(key: str, display_label: str, current_value: str) -> str:
edit_win.border()
# Redraw static content
edit_win.addstr(1, 2, f"Editing {display_label}", get_color("settings_default", bold=True))
edit_win.addstr(3, 2, "Current Value:", get_color("settings_default"))
edit_win.addstr(
1,
2,
t("ui.label.editing", default="Editing {label}", label=display_label),
get_color("settings_default", bold=True),
)
edit_win.addstr(
3, 2, t("ui.label.current_value", default="Current Value:"), get_color("settings_default")
)
for i, line in enumerate(wrapped_lines[:4]):
edit_win.addstr(4 + i, 2, line, get_color("settings_default"))
edit_win.addstr(7, 2, "New Value: ", get_color("settings_default"))
edit_win.addstr(
7, 2, t("ui.label.new_value", default="New Value: "), get_color("settings_default")
)
visible_text = user_input[scroll_offset : scroll_offset + input_width]
edit_win.addstr(row, col, " " * input_width, get_color("settings_default"))
@@ -278,10 +317,11 @@ def display_menu() -> tuple[Any, Any, List[str]]:
# Add Save button to the main window
if menu_state.show_save_option:
save_position = menu_height - 2
save_label = t("ui.save_changes", default=save_option)
menu_win.addstr(
save_position,
(w - len(save_option)) // 2,
save_option,
(w - len(save_label)) // 2,
save_label,
get_color("settings_save", reverse=(menu_state.selected_index == len(menu_state.current_menu))),
)
@@ -505,7 +545,7 @@ def json_editor(stdscr: curses.window, menu_state: Any) -> None:
# Exit the editor
if made_changes:
save_prompt = get_list_input(
"You have unsaved changes. Save before exiting?",
t("ui.confirm.save_before_exit", default="You have unsaved changes. Save before exiting?"),
None,
["Yes", "No", "Cancel"],
mandatory=True,
+31
View File
@@ -0,0 +1,31 @@
from typing import Optional
import contact.ui.default_config as config
from contact.utilities.control_utils import parse_ini_file
_translations = {}
_language = None
def _load_translations() -> None:
global _translations, _language
language = config.language
if _translations and _language == language:
return
translation_file = config.get_localisation_file(language)
_translations, _ = parse_ini_file(translation_file)
_language = language
def t(key: str, default: Optional[str] = None, **kwargs: object) -> str:
_load_translations()
text = _translations.get(key, default if default is not None else key)
try:
return text.format(**kwargs)
except Exception:
return text
def t_text(text: str, **kwargs: object) -> str:
return t(text, default=text, **kwargs)
+88 -22
View File
@@ -7,6 +7,7 @@ from typing import Any, Optional, List
from contact.ui.colors import get_color
from contact.ui.nav_utils import move_highlight, draw_arrows, wrap_text
from contact.ui.dialog import dialog
from contact.utilities.i18n import t, t_text
from contact.utilities.validation_rules import get_validation_for
from contact.utilities.singleton import menu_state
@@ -28,7 +29,7 @@ def invalid_input(window: curses.window, message: str, redraw_func: Optional[cal
"""Displays an invalid input message in the given window and redraws if needed."""
cursor_y, cursor_x = window.getyx()
curses.curs_set(0)
dialog("Invalid Input", message)
dialog(t("ui.dialog.invalid_input", default="Invalid Input"), t_text(message))
if redraw_func:
redraw_func() # Redraw the original window content that got obscured
else:
@@ -72,6 +73,7 @@ def get_text_input(prompt: str, selected_config: str, input_type: str) -> Option
input_win.attrset(get_color("window_frame"))
input_win.border()
prompt = t_text(prompt)
# Wrap the prompt text
wrapped_prompt = wrap_text(prompt, wrap_width=input_width)
row = 1
@@ -82,7 +84,7 @@ def get_text_input(prompt: str, selected_config: str, input_type: str) -> Option
if row >= height - 3: # Prevent overflow
break
prompt_text = "Enter new value: "
prompt_text = t("ui.prompt.enter_new_value", default="Enter new value: ")
input_win.addstr(row + 1, margin, prompt_text, get_color("settings_default"))
input_win.refresh()
@@ -125,41 +127,58 @@ def get_text_input(prompt: str, selected_config: str, input_type: str) -> Option
menu_state.need_redraw = True
if not user_input.strip():
invalid_input(input_win, "Value cannot be empty.", redraw_func=redraw_input_win)
invalid_input(
input_win,
t("ui.error.value_empty", default="Value cannot be empty."),
redraw_func=redraw_input_win,
)
continue
length = len(user_input)
if min_length == max_length and max_length is not None:
if length != min_length:
invalid_input(
input_win, f"Value must be exactly {min_length} characters long.", redraw_func=redraw_input_win
input_win,
t("ui.error.value_exact_length", default="Value must be exactly {length} characters long.", length=min_length),
redraw_func=redraw_input_win,
)
continue
else:
if length < min_length:
invalid_input(
input_win,
f"Value must be at least {min_length} characters long.",
t("ui.error.value_min_length", default="Value must be at least {length} characters long.", length=min_length),
redraw_func=redraw_input_win,
)
continue
if max_length is not None and length > max_length:
invalid_input(
input_win,
f"Value must be no more than {max_length} characters long.",
t("ui.error.value_max_length", default="Value must be no more than {length} characters long.", length=max_length),
redraw_func=redraw_input_win,
)
continue
if input_type is int:
if not user_input.isdigit():
invalid_input(input_win, "Only numeric digits (09) allowed.", redraw_func=redraw_input_win)
invalid_input(
input_win,
t("ui.error.digits_only", default="Only numeric digits (0-9) allowed."),
redraw_func=redraw_input_win,
)
continue
int_val = int(user_input)
if not (min_value <= int_val <= max_value):
invalid_input(
input_win, f"Enter a number between {min_value} and {max_value}.", redraw_func=redraw_input_win
input_win,
t(
"ui.error.number_range",
default="Enter a number between {min_value} and {max_value}.",
min_value=min_value,
max_value=max_value,
),
redraw_func=redraw_input_win,
)
continue
@@ -172,12 +191,21 @@ def get_text_input(prompt: str, selected_config: str, input_type: str) -> Option
if not (min_value <= float_val <= max_value):
invalid_input(
input_win,
f"Enter a number between {min_value} and {max_value}.",
t(
"ui.error.number_range",
default="Enter a number between {min_value} and {max_value}.",
min_value=min_value,
max_value=max_value,
),
redraw_func=redraw_input_win,
)
continue
except ValueError:
invalid_input(input_win, "Must be a valid floating point number.", redraw_func=redraw_input_win)
invalid_input(
input_win,
t("ui.error.float_invalid", default="Must be a valid floating point number."),
redraw_func=redraw_input_win,
)
continue
else:
curses.curs_set(0)
@@ -276,13 +304,21 @@ def get_admin_key_input(current_value: List[bytes]) -> Optional[List[str]]:
while True:
admin_key_win.erase()
admin_key_win.border()
admin_key_win.addstr(1, 2, "Edit up to 3 Admin Keys:", get_color("settings_default", bold=True))
admin_key_win.addstr(
1,
2,
t("ui.prompt.edit_admin_keys", default="Edit up to 3 Admin Keys:"),
get_color("settings_default", bold=True),
)
# Display current values, allowing editing
for i, line in enumerate(user_values):
prefix = "" if i == cursor_pos else " " # Highlight the current line
admin_key_win.addstr(
3 + i, 2, f"{prefix}Admin Key {i + 1}: ", get_color("settings_default", bold=(i == cursor_pos))
3 + i,
2,
f"{prefix}{t('ui.label.admin_key', default='Admin Key')} {i + 1}: ",
get_color("settings_default", bold=(i == cursor_pos)),
)
admin_key_win.addstr(3 + i, 18, line) # Align text for easier editing
@@ -292,7 +328,7 @@ def get_admin_key_input(current_value: List[bytes]) -> Optional[List[str]]:
# Show error message if needed
if invalid_input:
admin_key_win.addstr(7, 2, invalid_input, get_color("settings_default", bold=True))
admin_key_win.addstr(7, 2, t_text(invalid_input), get_color("settings_default", bold=True))
admin_key_win.refresh()
key = admin_key_win.getch()
@@ -312,7 +348,10 @@ def get_admin_key_input(current_value: List[bytes]) -> Optional[List[str]]:
curses.curs_set(0)
return user_values # Return the edited Base64 values
else:
invalid_input = "Error: Each key must be valid Base64 and 32 bytes long!"
invalid_input = t(
"ui.error.admin_key_invalid",
default="Error: Each key must be valid Base64 and 32 bytes long!",
)
elif key == curses.KEY_UP: # Move cursor up
cursor_pos = (cursor_pos - 1) % len(user_values)
elif key == curses.KEY_DOWN: # Move cursor down
@@ -353,13 +392,21 @@ def get_repeated_input(current_value: List[str]) -> Optional[str]:
def redraw():
repeated_win.erase()
repeated_win.border()
repeated_win.addstr(1, 2, "Edit up to 3 Values:", get_color("settings_default", bold=True))
repeated_win.addstr(
1,
2,
t("ui.prompt.edit_values", default="Edit up to 3 Values:"),
get_color("settings_default", bold=True),
)
win_h, win_w = repeated_win.getmaxyx()
for i, line in enumerate(user_values):
prefix = "" if i == cursor_pos else " "
repeated_win.addstr(
3 + i, 2, f"{prefix}Value{i + 1}: ", get_color("settings_default", bold=(i == cursor_pos))
3 + i,
2,
f"{prefix}{t('ui.label.value', default='Value')}{i + 1}: ",
get_color("settings_default", bold=(i == cursor_pos)),
)
repeated_win.addstr(3 + i, 18, line[: max(0, win_w - 20)]) # Prevent overflow
@@ -434,9 +481,21 @@ def get_fixed32_input(current_value: int) -> int:
def redraw():
fixed32_win.erase()
fixed32_win.border()
fixed32_win.addstr(1, 2, "Enter an IP address (xxx.xxx.xxx.xxx):", get_color("settings_default", bold=True))
fixed32_win.addstr(3, 2, f"Current: {ip_string}", get_color("settings_default"))
fixed32_win.addstr(5, 2, f"New value: {user_input}", get_color("settings_default"))
fixed32_win.addstr(
1,
2,
t("ui.prompt.enter_ip", default="Enter an IP address (xxx.xxx.xxx.xxx):"),
get_color("settings_default", bold=True),
)
fixed32_win.addstr(
3, 2, f"{t('ui.label.current', default='Current')}: {ip_string}", get_color("settings_default")
)
fixed32_win.addstr(
5,
2,
f"{t('ui.label.new_value', default='New value')}: {user_input}",
get_color("settings_default"),
)
fixed32_win.refresh()
while True:
@@ -467,7 +526,12 @@ def get_fixed32_input(current_value: int) -> int:
curses.curs_set(0)
return int(ipaddress.ip_address(user_input))
else:
fixed32_win.addstr(7, 2, "Invalid IP address. Try again.", get_color("settings_default", bold=True))
fixed32_win.addstr(
7,
2,
t("ui.error.ip_invalid", default="Invalid IP address. Try again."),
get_color("settings_default", bold=True),
)
fixed32_win.refresh()
curses.napms(1500)
user_input = ""
@@ -513,15 +577,17 @@ def get_list_input(
visible_height = list_win.getmaxyx()[0] - 5
def redraw_list_ui():
translated_prompt = t_text(prompt)
list_win.erase()
list_win.border()
list_win.addstr(1, 2, prompt, get_color("settings_default", bold=True))
list_win.addstr(1, 2, translated_prompt, get_color("settings_default", bold=True))
win_h, win_w = list_win.getmaxyx()
pad_w = max(1, win_w - 8)
for idx, item in enumerate(list_options):
color = get_color("settings_default", reverse=(idx == selected_index))
list_pad.addstr(idx, 0, item[:pad_w].ljust(pad_w), color)
display_item = t_text(item)
list_pad.addstr(idx, 0, display_item[:pad_w].ljust(pad_w), color)
list_win.refresh()
list_pad.refresh(