From c3c0ac51330706ee3b7b1ae4bb029ab94fc631ee Mon Sep 17 00:00:00 2001 From: Noah-Jaffe Date: Sat, 12 Sep 2026 10:43:11 -0400 Subject: [PATCH 01/16] better error handling for settings inputs --- src/windows/main/main_app.py | 15 +++++++++++++-- src/windows/options/playback/delay.py | 2 +- src/windows/options/playback/repeat.py | 2 +- src/windows/options/playback/speed.py | 2 +- src/windows/options/playback/time_gui.py | 6 +++--- 5 files changed, 19 insertions(+), 8 deletions(-) diff --git a/src/windows/main/main_app.py b/src/windows/main/main_app.py index 8ab6d3c..1f97aa1 100644 --- a/src/windows/main/main_app.py +++ b/src/windows/main/main_app.py @@ -67,7 +67,8 @@ def __init__(self): self.menu = MenuBar(self) # Menu Bar self.macro = Macro(self) - self.validate_cmd = self.register(self.validate_input) + self.validate_cmd_float = self.register(self.validate_input_float) + self.validate_cmd_int = self.register(self.validate_input_int) self.hotkeyManager = HotkeysManager(self) @@ -141,7 +142,7 @@ def systemTray(self): self.icon = Icon("name", image, "PyMacroRecord", menu) self.icon.run() - def validate_input(self, action, value_if_allowed): + def validate_input_float(self, action, value_if_allowed): """Prevents from adding letters on an Entry label""" if action == "1": # Insert try: @@ -151,6 +152,16 @@ def validate_input(self, action, value_if_allowed): return False return True + def validate_input_int(self, action, value_if_allowed): + """Prevents from adding letters on an Entry label""" + if action == "1": # Insert + try: + int(value_if_allowed) + return True + except ValueError: + return False + return True + def quit_software(self, force=False): if not self.macro_saved and self.macro_recorded and not force: wantToSave = confirm_save(self) diff --git a/src/windows/options/playback/delay.py b/src/windows/options/playback/delay.py index 8f9afe9..f0e5e2a 100644 --- a/src/windows/options/playback/delay.py +++ b/src/windows/options/playback/delay.py @@ -12,7 +12,7 @@ def __init__(self, parent, main_app): Label(self, text=main_app.text_content["options_menu"]["playback_menu"]["delay_settings"]["sub_text"], font=('Segoe UI', 10)).pack(side=TOP, pady=10) userSettings = main_app.settings.settings_dict setNewDelayInput = Spinbox(self, from_=1, to=100000000, width=7, validate="key", - validatecommand=(main_app.validate_cmd, "%d", "%P")) + validatecommand=(main_app.validate_cmd_float, "%d", "%P")) setNewDelayInput.delete(0, "end") setNewDelayInput.insert(0, str(userSettings["Playback"]["Repeat"]["Delay"])) setNewDelayInput.pack(pady=20) diff --git a/src/windows/options/playback/repeat.py b/src/windows/options/playback/repeat.py index ca6c027..523d819 100644 --- a/src/windows/options/playback/repeat.py +++ b/src/windows/options/playback/repeat.py @@ -27,7 +27,7 @@ def __init__(self, parent, main_app): infiniteCheck.pack(pady=5) repeatTimes = Spinbox(self, from_=1, to=100000000, width=7, validate="key", - validatecommand=(main_app.validate_cmd, "%d", "%P")) + validatecommand=(main_app.self.validate_cmd_int, "%d", "%P")) repeatTimes.delete(0, "end") repeatTimes.insert(0, userSettings["Playback"]["Repeat"]["Times"]) repeatTimes.pack(pady=5) diff --git a/src/windows/options/playback/speed.py b/src/windows/options/playback/speed.py index a55c300..8867908 100644 --- a/src/windows/options/playback/speed.py +++ b/src/windows/options/playback/speed.py @@ -12,7 +12,7 @@ def __init__(self, parent, main_app): Label(self, text=main_app.text_content["options_menu"]["playback_menu"]["speed_settings"]["sub_text"], font=('Segoe UI', 10)).pack(side=TOP, pady=10) userSettings = main_app.settings.settings_dict setNewSpeedInput = Spinbox(self, from_=0.1, to=10, width=7, validate="key", - validatecommand=(main_app.validate_cmd, "%d", "%P")) + validatecommand=(main_app.validate_cmd_float, "%d", "%P")) setNewSpeedInput.insert(0, str(userSettings["Playback"]["Speed"])) setNewSpeedInput.pack(pady=20) buttonArea = Frame(self) diff --git a/src/windows/options/playback/time_gui.py b/src/windows/options/playback/time_gui.py index 980aa88..5ffdb7d 100644 --- a/src/windows/options/playback/time_gui.py +++ b/src/windows/options/playback/time_gui.py @@ -35,7 +35,7 @@ def __init__(self, parent, main_app, type): to=24, width=10, validate="key", - validatecommand=(main_app.validate_cmd, "%d", "%P"), + validatecommand=(main_app.validate_cmd_int, "%d", "%P"), ) hourValue = str(value // 3600) if self.type == "Scheduled" and self.time_format == "12 hours" and self.time_string == "PM" and int(hourValue) >= 12: @@ -59,7 +59,7 @@ def __init__(self, parent, main_app, type): to=60, width=10, validate="key", - validatecommand=(main_app.validate_cmd, "%d", "%P"), + validatecommand=(main_app.validate_cmd_int, "%d", "%P"), ) minInput.insert( 0, str((value % 3600) // 60) @@ -75,7 +75,7 @@ def __init__(self, parent, main_app, type): to=60, width=10, validate="key", - validatecommand=(main_app.validate_cmd, "%d", "%P"), + validatecommand=(main_app.validate_cmd_float, "%d", "%P"), ) secInput.insert(0, str(value % 60)) secInput.pack() From 62b14e5b97fba0da542833d10d268d10ff877114 Mon Sep 17 00:00:00 2001 From: Noah-Jaffe Date: Sat, 12 Sep 2026 10:43:34 -0400 Subject: [PATCH 02/16] added language support for new randomized delay, also fixed whitespace inconsistency --- src/langs/bg.json | 178 +++++++++++----------- src/langs/de.json | 8 + src/langs/en.json | 8 + src/langs/eo.json | 8 + src/langs/es.json | 8 + src/langs/fr.json | 8 + src/langs/it.json | 340 ++++++++++++++++++++++--------------------- src/langs/ko.json | 340 ++++++++++++++++++++++--------------------- src/langs/nl.json | 8 + src/langs/pt-BR.json | 330 +++++++++++++++++++++-------------------- src/langs/ru-RU.json | 8 + src/langs/tr.json | 14 +- src/langs/zh-CN.json | 14 +- 13 files changed, 688 insertions(+), 584 deletions(-) diff --git a/src/langs/bg.json b/src/langs/bg.json index d9936da..7b29f97 100644 --- a/src/langs/bg.json +++ b/src/langs/bg.json @@ -36,94 +36,102 @@ }, "options_menu": { "options_text": "Опции", - "playback_menu": { - "playback_text": "Възпроизвеждане", - "speed_text": "Скорост", - "speed_settings": { - "title": "Настройки на скоростта", - "sub_text": "Въведете число за скорост между 0.1 и 10", - "error_new_value": "Стойността на скоростта трябва да бъде между 0.1 и 10!" - }, - "repeat_text": "Повтаряне", - "repeat_settings": { - "title": "Настройки за повтаряне", - "infinite_repeat": "Безкрайно повторение?", - "sub_text": "Въведете броя на повторенията", - "error_new_value": "Не можете да имате по-малко от 1 повторение." - }, - "for_text": "За", - "for_settings": { - "title": "Настройки за За" - }, - "interval_text": "Интервал", - "interval_settings": { - "title": "Настройки на интервала" - }, - "for_interval_settings": { - "hours_text": "Часове", - "minutes_text": "Минути", - "seconds_text": "Секунди", - "error_new_value_multiple": "Въведените стойности са грешни", - "error_new_value_single": "Въведената стойност е грешна" - }, - "scheduled_text": "Планирани", - "scheduled_settings": { - "title": "Настройки за планирани" - }, - "delay_text": "Закъснение", - "delay_settings": { - "title": "Настройки за закъснение", - "sub_text": "Въведете закъснение между повторенията", - "error_new_value": "Не можете да имате закъснение по-малко от 0." - } - }, - "recordings_menu": { - "recordings_text": "Записи", - "mouse_movement_text": "Движение на мишката", - "mouse_click_text": "Щракване с мишка", - "keyboard_text": "Клавиатура", - "show_events_statut": "Показване на събития в лентата за състояние" - }, - "json_compact": "Компактирани макро данни", - "settings_menu": { - "settings_text": "Настройки", - "always_import_macro_settings": "Винаги импортирай макро настройки", - "lang_text": "Език", - "lang_settings": { - "title": "Настройки на езика", - "sub_text": "Изберете вашия език" + "playback_menu": { + "playback_text": "Възпроизвеждане", + "speed_text": "Скорост", + "speed_settings": { + "title": "Настройки на скоростта", + "sub_text": "Въведете число за скорост между 0.1 и 10", + "error_new_value": "Стойността на скоростта трябва да бъде между 0.1 и 10!" + }, + "repeat_text": "Повтаряне", + "repeat_settings": { + "title": "Настройки за повтаряне", + "infinite_repeat": "Безкрайно повторение?", + "sub_text": "Въведете броя на повторенията", + "error_new_value": "Не можете да имате по-малко от 1 повторение." + }, + "for_text": "За", + "for_settings": { + "title": "Настройки за За" + }, + "interval_text": "Интервал", + "interval_settings": { + "title": "Настройки на интервала" + }, + "for_interval_settings": { + "hours_text": "Часове", + "minutes_text": "Минути", + "seconds_text": "Секунди", + "error_new_value_multiple": "Въведените стойности са грешни", + "error_new_value_single": "Въведената стойност е грешна" + }, + "scheduled_text": "Планирани", + "scheduled_settings": { + "title": "Настройки за планирани" + }, + "delay_text": "Закъснение", + "delay_settings": { + "title": "Настройки за закъснение", + "sub_text": "Въведете закъснение между повторенията", + "error_new_value": "Не можете да имате закъснение по-малко от 0." + }, + "randomized_delay_text": "Случайно забавяне", + "randomized_delay_settings": { + "title": "Настройки за случайно забавяне", + "sub_text": "Конфигурирайте границите на случайното забавяне между действията в милисекунди.\nСтойности под нулата ще позволят намаляване на времето до следващото действие,\n а стойности над нулата ще позволят увеличаване на времето до следващото действие.", + "lower_text": "Долна граница:", + "upper_text": "Горна граница:", + "error_new_value": "И двете граници на случайното забавяне трябва да бъдат валидни числа." + } }, - "hotkeys_text": "Бързи клавиши", - "hotkeys_settings": { - "title": "Настройки за бързи клавиши", - "start_record_text": "Започни запис", - "stop_record_text": "Спри запис", - "start_playback_text": "Започни възпроизвеждане", - "stop_playback_text": "Спри възпроизвеждане", - "clear_text": "Изчисти", - "please_key_text": "Моля, натиснете клавиш", - "error_hotkeys": "Не можете да имате същите бързи клавиши за започване на запис и започване на възпроизвеждане" + "recordings_menu": { + "recordings_text": "Записи", + "mouse_movement_text": "Движение на мишката", + "mouse_click_text": "Щракване с мишка", + "keyboard_text": "Клавиатура", + "show_events_statut": "Показване на събития в лентата за състояние" }, - "minimization_text": "Минимизиране", - "minimization_toast": "PyMacroRecord беше минимизиран.", - "minimization_menu": { - "minimization_when_playing_text": "Минимизиране повреме на възпроизвеждане", - "minimization_when_recording_text": "Минимизиране повреме на запис" + "json_compact": "Компактирани макро данни", + "settings_menu": { + "settings_text": "Настройки", + "always_import_macro_settings": "Винаги импортирай макро настройки", + "lang_text": "Език", + "lang_settings": { + "title": "Настройки на езика", + "sub_text": "Изберете вашия език" + }, + "hotkeys_text": "Бързи клавиши", + "hotkeys_settings": { + "title": "Настройки за бързи клавиши", + "start_record_text": "Започни запис", + "stop_record_text": "Спри запис", + "start_playback_text": "Започни възпроизвеждане", + "stop_playback_text": "Спри възпроизвеждане", + "clear_text": "Изчисти", + "please_key_text": "Моля, натиснете клавиш", + "error_hotkeys": "Не можете да имате същите бързи клавиши за започване на запис и започване на възпроизвеждане" + }, + "minimization_text": "Минимизиране", + "minimization_toast": "PyMacroRecord беше минимизиран.", + "minimization_menu": { + "minimization_when_playing_text": "Минимизиране повреме на възпроизвеждане", + "minimization_when_recording_text": "Минимизиране повреме на запис" + }, + "after_playback_text": "След възпроизвеждане", + "after_playback_settings": { + "when_playback_complete_text": "След завършване на възпроизвеждането", + "title": "Настройки след възпроизвеждане", + "sub_text": "При завършване на възпроизвеждането", + "idle": "Неактивно", + "quit_software": "Изключване на софтуера", + "standby": "Изчакване", + "log_off_computer": "Излизане от компютъра", + "turn_off_computer": "Изключване на компютъра", + "restart_computer": "Рестартиране на компютъра", + "hibernate_if_enabled": "Хибернация (ако е възможно)" + } }, - "after_playback_text": "След възпроизвеждане", - "after_playback_settings": { - "when_playback_complete_text": "След завършване на възпроизвеждането", - "title": "Настройки след възпроизвеждане", - "sub_text": "При завършване на възпроизвеждането", - "idle": "Неактивно", - "quit_software": "Изключване на софтуера", - "standby": "Изчакване", - "log_off_computer": "Излизане от компютъра", - "turn_off_computer": "Изключване на компютъра", - "restart_computer": "Рестартиране на компютъра", - "hibernate_if_enabled": "Хибернация (ако е възможно)" - } - }, "others_menu": { "others_text": "Други", "check_update_text": "Проверка за актуализации", diff --git a/src/langs/de.json b/src/langs/de.json index 9164a24..0c1e214 100644 --- a/src/langs/de.json +++ b/src/langs/de.json @@ -75,6 +75,14 @@ "title": "Verzögerungseinstellung", "sub_text": "Verzögerung zwischen den Ausführungen", "error_new_value": "Eine Minimum-Verzögerung von Null ist nötig." + }, + "randomized_delay_text": "Zufällige Verzögerung", + "randomized_delay_settings": { + "title": "Einstellungen für zufällige Verzögerung", + "sub_text": "Konfigurieren Sie die Grenzen der zufälligen Verzögerung zwischen Aktionen in Millisekunden.\nWerte unter null ermöglichen eine Verkürzung der Zeit bis zur nächsten Aktion,\n während Werte über null eine Verlängerung der Zeit bis zur nächsten Aktion ermöglichen.", + "lower_text": "Untergrenze:", + "upper_text": "Obergrenze:", + "error_new_value": "Beide Grenzen der zufälligen Verzögerung müssen gültige Zahlen sein." } }, "recordings_menu": { diff --git a/src/langs/en.json b/src/langs/en.json index ea16cfe..ee72ed2 100644 --- a/src/langs/en.json +++ b/src/langs/en.json @@ -75,6 +75,14 @@ "title": "Delay settings", "sub_text": "Enter delay between repeat", "error_new_value": "You cannot have less than 0 delay." + }, + "randomized_delay_text": "Randomized delay", + "randomized_delay_settings": { + "title": "Randomized delay settings", + "sub_text": "Configure randomized delay bounds between actions in milliseconds.\nValues below zero will allow for decreased time to next action,\n and above zero will allow for increased time to next action.", + "lower_text": "Lower:", + "upper_text": "Upper:", + "error_new_value": "Both randomized delay bounds must be valid numbers." } }, "recordings_menu": { diff --git a/src/langs/eo.json b/src/langs/eo.json index 1ce9026..f10247c 100644 --- a/src/langs/eo.json +++ b/src/langs/eo.json @@ -75,6 +75,14 @@ "title": "Prokrast-agordoj", "sub_text": "Enmetu prokraston inter ripetoj", "error_new_value": "Vi ne povas havi malpli ol 0 da prokrasto." + }, + "randomized_delay_text": "Hazarda prokrasto", + "randomized_delay_settings": { + "title": "Agordoj de hazarda prokrasto", + "sub_text": "Agordu la limojn de la hazarda prokrasto inter agoj en milisekundoj.\nValoroj sub nul permesos malpli da tempo ĝis la sekva ago,\n kaj valoroj super nul permesos pli da tempo ĝis la sekva ago.", + "lower_text": "Malsupra:", + "upper_text": "Supra:", + "error_new_value": "Ambaŭ limoj de la hazarda prokrasto devas esti validaj nombroj." } }, "recordings_menu": { diff --git a/src/langs/es.json b/src/langs/es.json index e6d07fc..108eb5b 100644 --- a/src/langs/es.json +++ b/src/langs/es.json @@ -75,6 +75,14 @@ "title": "Ajustes de retrazo", "sub_text": "Introduzca el retraso entre repetición", "error_new_value": "No puede tener menos de 0 retrasos" + }, + "randomized_delay_text": "Retraso aleatorio", + "randomized_delay_settings": { + "title": "Configuración del retraso aleatorio", + "sub_text": "Configure los límites del retraso aleatorio entre acciones en milisegundos.\nLos valores inferiores a cero permitirán reducir el tiempo hasta la siguiente acción,\n mientras que los valores superiores a cero permitirán aumentar el tiempo hasta la siguiente acción.", + "lower_text": "Inferior:", + "upper_text": "Superior:", + "error_new_value": "Ambos límites del retraso aleatorio deben ser números válidos." } }, "recordings_menu": { diff --git a/src/langs/fr.json b/src/langs/fr.json index 08cf4db..05d4b0e 100644 --- a/src/langs/fr.json +++ b/src/langs/fr.json @@ -75,6 +75,14 @@ "title": "Réglage du délai", "sub_text": "Entrez un délai entre chaque répétition", "error_new_value": "Vous ne pouvez pas allez en dessous de 0 délai." + }, + "randomized_delay_text": "Délai aléatoire", + "randomized_delay_settings": { + "title": "Paramètres du délai aléatoire", + "sub_text": "Configurez les limites du délai aléatoire entre les actions en millisecondes.\nLes valeurs inférieures à zéro permettront de réduire le temps avant l'action suivante,\n tandis que les valeurs supérieures à zéro permettront d'augmenter le temps avant l'action suivante.", + "lower_text": "Inférieure :", + "upper_text": "Supérieure :", + "error_new_value": "Les deux limites du délai aléatoire doivent être des nombres valides." } }, "recordings_menu": { diff --git a/src/langs/it.json b/src/langs/it.json index da28191..baf075a 100644 --- a/src/langs/it.json +++ b/src/langs/it.json @@ -1,176 +1,184 @@ { - "information": { - "author": "takiem", - "lang_short": "it", - "lang_long": "Italiano" + "information": { + "author": "takiem", + "lang_short": "it", + "lang_long": "Italiano" + }, + "content": { + "global": { + "confirm_button": "Conferma", + "cancel_button": "Annulla", + "close_button": "Chiudi", + "error": "Errore", + "previous_text": "Precedente", + "next_text": "Successivo", + "confirm_save": "Vuoi salvare la tua registrazione?", + "confirm": "Conferma", + "information": "Informazione", + "restart_software_text": "È necessario riavviare il software per applicare le modifiche.", + "load_macro_settings": "Import macro settings too?" }, - "content": { - "global": { - "confirm_button": "Conferma", - "cancel_button": "Annulla", - "close_button": "Chiudi", - "error": "Errore", - "previous_text": "Precedente", - "next_text": "Successivo", - "confirm_save": "Vuoi salvare la tua registrazione?", - "confirm": "Conferma", - "information": "Informazione", - "restart_software_text": "È necessario riavviare il software per applicare le modifiche.", - "load_macro_settings": "Import macro settings too?" + "new_version": { + "title": "Aggiornamento del software", + "sub_text_1": "Nuova versione", + "sub_text_2": "disponibile!", + "sub_text_3": "Vuoi scaricarlo ora?", + "ignore_button": "Ignora", + "remind_later_button": "Ricordamelo più tardi", + "download_button": "Scarica l'aggiornamento" + }, + "file_menu": { + "file_text": "File", + "new_text": "Nuovo", + "load_text": "Carica", + "save_text": "Salva", + "save_as_text": "Salva come" + }, + "options_menu": { + "options_text": "Opzioni", + "playback_menu": { + "playback_text": "Riproduzione", + "speed_text": "Velocità", + "speed_settings": { + "title": "Impostazioni della velocità", + "sub_text": "Inserisci un numero di velocità tra 0,1 e 10", + "error_new_value": "Il valore della velocità deve essere compreso tra 0,1 e 10!" + }, + "repeat_text": "Ripeti", + "repeat_settings": { + "title": "Impostazioni della ripetizione", + "infinite_repeat": "Ripetizione infinita?", + "sub_text": "Inserisci il numero di ripetizioni", + "error_new_value": "Non puoi avere meno di una ripetizione." + }, + "for_text": "Per", + "for_settings": { + "title": "Impostazioni per" + }, + "interval_text": "Intervallo", + "interval_settings": { + "title": "Impostazioni dell'intervallo" + }, + "for_interval_settings": { + "hours_text": "Ore", + "minutes_text": "Minuti", + "seconds_text": "Secondi", + "error_new_value_multiple": "L'input non è corretto", + "error_new_value_single": "L'input non è corretto" }, - "new_version": { - "title": "Aggiornamento del software", - "sub_text_1": "Nuova versione", - "sub_text_2": "disponibile!", - "sub_text_3": "Vuoi scaricarlo ora?", - "ignore_button": "Ignora", - "remind_later_button": "Ricordamelo più tardi", - "download_button": "Scarica l'aggiornamento" + "scheduled_text": "In programma", + "scheduled_settings": { + "title": "Impostazioni programmate" }, - "file_menu": { - "file_text": "File", - "new_text": "Nuovo", - "load_text": "Carica", - "save_text": "Salva", - "save_as_text": "Salva come" + "delay_text": "Ritardo", + "delay_settings": { + "title": "Impostazioni del ritardo", + "sub_text": "Inserisci il ritardo tra le ripetizioni", + "error_new_value": "Il ritardo non può essere inferiore a 0." }, - "options_menu": { - "options_text": "Opzioni", - "playback_menu": { - "playback_text": "Riproduzione", - "speed_text": "Velocità", - "speed_settings": { - "title": "Impostazioni della velocità", - "sub_text": "Inserisci un numero di velocità tra 0,1 e 10", - "error_new_value": "Il valore della velocità deve essere compreso tra 0,1 e 10!" - }, - "repeat_text": "Ripeti", - "repeat_settings": { - "title": "Impostazioni della ripetizione", - "infinite_repeat": "Ripetizione infinita?", - "sub_text": "Inserisci il numero di ripetizioni", - "error_new_value": "Non puoi avere meno di una ripetizione." - }, - "for_text": "Per", - "for_settings": { - "title": "Impostazioni per" - }, - "interval_text": "Intervallo", - "interval_settings": { - "title": "Impostazioni dell'intervallo" - }, - "for_interval_settings": { - "hours_text": "Ore", - "minutes_text": "Minuti", - "seconds_text": "Secondi", - "error_new_value_multiple": "L'input non è corretto", - "error_new_value_single": "L'input non è corretto" - }, - "scheduled_text": "In programma", - "scheduled_settings": { - "title": "Impostazioni programmate" - }, - "delay_text": "Ritardo", - "delay_settings": { - "title": "Impostazioni del ritardo", - "sub_text": "Inserisci il ritardo tra le ripetizioni", - "error_new_value": "Il ritardo non può essere inferiore a 0." - } - }, - "recordings_menu": { - "recordings_text": "Registrazioni", - "mouse_movement_text": "Movimento del mouse", - "mouse_click_text": "Clic del mouse", - "keyboard_text": "Tastiera", - "show_events_statut": "Mostra eventi sulla barra di stato" - }, - "json_compact": "Dati macro compatti", - "settings_menu": { - "settings_text": "Impostazioni", - "always_import_macro_settings": "Importa sempre le impostazioni macro", - "lang_text": "Lingua", - "lang_settings": { - "title": "Impostazioni della lingua", - "sub_text": "Scegli la tua lingua" - }, - "hotkeys_text": "Tasti di scelta rapida", - "hotkeys_settings": { - "title": "Impostazioni dei tasti di scelta rapida", - "start_record_text": "Inizia registrazione", - "stop_record_text": "Ferma registrazione", - "start_playback_text": "Avvia riproduzione", - "stop_playback_text": "Ferma riproduzione", - "clear_text": "Pulisci", - "please_key_text": "Premi un tasto", - "error_hotkeys": "Non puoi assegnare gli stessi tasti di scelta rapida per avviare la registrazione e la riproduzione" - }, - "minimization_text": "Minimizzazione", - "minimization_toast": "PyMacroRecord è stato ridotto a icona.", - "minimization_menu": { - "minimization_when_playing_text": "Minimizzazione durante la riproduzione", - "minimization_when_recording_text": "Minimizzazione durante la registrazione" - }, - "after_playback_text": "Dopo la riproduzione", - "after_playback_settings": { - "when_playback_complete_text": "Dopo il completamento della riproduzione", - "title": "Impostazioni dopo la riproduzione", - "sub_text": "Al termine della riproduzione", - "idle": "Inattivo", - "quit_software": "Esci dal software", - "standby": "Standby", - "log_off_computer": "Disconnetti computer", - "turn_off_computer": "Spegni computer", - "restart computer": "Riavvia computer", - "hibernate_if_enabled": "Ibernazione (se abilitata)" - } - }, - "others_menu": { - "others_text": "Altri", - "check_update_text": "Verifica aggiornamenti", - "reset_settings_text": "Reimposta impostazioni", - "reset_settings_confirmation": "Sei sicuro di voler reimpostare le impostazioni?", - "fixed_timestamp_text": "Timestamp fisso", - "fixed_timestamp_settings": { - "title": "Impostazioni del timestamp fisso", - "sub_text": "Inserisci timestamp fisso" - } - } + "randomized_delay_text": "Ritardo casuale", + "randomized_delay_settings": { + "title": "Impostazioni del ritardo casuale", + "sub_text": "Configura i limiti del ritardo casuale tra le azioni in millisecondi.\nI valori inferiori a zero consentiranno di ridurre il tempo fino all'azione successiva,\n mentre i valori superiori a zero consentiranno di aumentare il tempo fino all'azione successiva.", + "lower_text": "Inferiore:", + "upper_text": "Superiore:", + "error_new_value": "Entrambi i limiti del ritardo casuale devono essere numeri validi." + } + }, + "recordings_menu": { + "recordings_text": "Registrazioni", + "mouse_movement_text": "Movimento del mouse", + "mouse_click_text": "Clic del mouse", + "keyboard_text": "Tastiera", + "show_events_statut": "Mostra eventi sulla barra di stato" + }, + "json_compact": "Dati macro compatti", + "settings_menu": { + "settings_text": "Impostazioni", + "always_import_macro_settings": "Importa sempre le impostazioni macro", + "lang_text": "Lingua", + "lang_settings": { + "title": "Impostazioni della lingua", + "sub_text": "Scegli la tua lingua" + }, + "hotkeys_text": "Tasti di scelta rapida", + "hotkeys_settings": { + "title": "Impostazioni dei tasti di scelta rapida", + "start_record_text": "Inizia registrazione", + "stop_record_text": "Ferma registrazione", + "start_playback_text": "Avvia riproduzione", + "stop_playback_text": "Ferma riproduzione", + "clear_text": "Pulisci", + "please_key_text": "Premi un tasto", + "error_hotkeys": "Non puoi assegnare gli stessi tasti di scelta rapida per avviare la registrazione e la riproduzione" }, - "help_menu": { - "help_text": "Aiuto", - "tutorial_text": "Tutorial", - "about_text": "Informazioni", - "website_text": "Sito web", - "about_settings": { - "title": "Informazioni", - "publisher_text": "Editore", - "version_text": "Versione", - "license_text": "Sotto licenza", - "version_check_update_text": { - "checking": "Verifica...", - "up_to_date": "Aggiornato", - "outdated": "Obsoleto", - "disabled": "Verifica aggiornamenti disabilitata", - "failed": "Impossibile verificare nuovi aggiornamenti" - } - } + "minimization_text": "Minimizzazione", + "minimization_toast": "PyMacroRecord è stato ridotto a icona.", + "minimization_menu": { + "minimization_when_playing_text": "Minimizzazione durante la riproduzione", + "minimization_when_recording_text": "Minimizzazione durante la registrazione" }, - "others_menu": { - "others_text": "Altri", - "donors_text": "Donatori", - "donors_settings": { - "title": "Donatori", - "sub_text": "Tutti i donatori", - "load_donors": "Caricamento donatori...", - "want_be_donor": "Vuoi diventare donatore? Clicca qui!", - "cant_get_donors": "Impossibile ottenere i donatori :(" - }, - "translators_text": "Traduttori", - "translators_settings": { - "title": "Traduttori", - "sub_text": "Tutti i traduttori", - "page": "Pagina" - } + "after_playback_text": "Dopo la riproduzione", + "after_playback_settings": { + "when_playback_complete_text": "Dopo il completamento della riproduzione", + "title": "Impostazioni dopo la riproduzione", + "sub_text": "Al termine della riproduzione", + "idle": "Inattivo", + "quit_software": "Esci dal software", + "standby": "Standby", + "log_off_computer": "Disconnetti computer", + "turn_off_computer": "Spegni computer", + "restart computer": "Riavvia computer", + "hibernate_if_enabled": "Ibernazione (se abilitata)" + } + }, + "others_menu": { + "others_text": "Altri", + "check_update_text": "Verifica aggiornamenti", + "reset_settings_text": "Reimposta impostazioni", + "reset_settings_confirmation": "Sei sicuro di voler reimpostare le impostazioni?", + "fixed_timestamp_text": "Timestamp fisso", + "fixed_timestamp_settings": { + "title": "Impostazioni del timestamp fisso", + "sub_text": "Inserisci timestamp fisso" } + } + }, + "help_menu": { + "help_text": "Aiuto", + "tutorial_text": "Tutorial", + "about_text": "Informazioni", + "website_text": "Sito web", + "about_settings": { + "title": "Informazioni", + "publisher_text": "Editore", + "version_text": "Versione", + "license_text": "Sotto licenza", + "version_check_update_text": { + "checking": "Verifica...", + "up_to_date": "Aggiornato", + "outdated": "Obsoleto", + "disabled": "Verifica aggiornamenti disabilitata", + "failed": "Impossibile verificare nuovi aggiornamenti" + } + } + }, + "others_menu": { + "others_text": "Altri", + "donors_text": "Donatori", + "donors_settings": { + "title": "Donatori", + "sub_text": "Tutti i donatori", + "load_donors": "Caricamento donatori...", + "want_be_donor": "Vuoi diventare donatore? Clicca qui!", + "cant_get_donors": "Impossibile ottenere i donatori :(" + }, + "translators_text": "Traduttori", + "translators_settings": { + "title": "Traduttori", + "sub_text": "Tutti i traduttori", + "page": "Pagina" + } } + } } diff --git a/src/langs/ko.json b/src/langs/ko.json index f4764f6..33f9967 100644 --- a/src/langs/ko.json +++ b/src/langs/ko.json @@ -1,176 +1,184 @@ { - "information": { - "author": "Jinwoo.Seo", - "lang_short": "ko", - "lang_long": "한국어" + "information": { + "author": "Jinwoo.Seo", + "lang_short": "ko", + "lang_long": "한국어" + }, + "content": { + "global": { + "confirm_button": "확인", + "cancel_button": "취소", + "close_button": "닫기", + "error": "오류", + "previous_text": "이전", + "next_text": "다음", + "confirm_save": "녹화를 저장하시겠습니까?", + "confirm": "확인", + "information": "정보", + "restart_software_text": "변경 사항을 적용하려면 소프트웨어를 다시 시작해야 합니다.", + "load_macro_settings": "매크로 설정도 가져오시겠습니까?" }, - "content": { - "global": { - "confirm_button": "확인", - "cancel_button": "취소", - "close_button": "닫기", - "error": "오류", - "previous_text": "이전", - "next_text": "다음", - "confirm_save": "녹화를 저장하시겠습니까?", - "confirm": "확인", - "information": "정보", - "restart_software_text": "변경 사항을 적용하려면 소프트웨어를 다시 시작해야 합니다.", - "load_macro_settings": "매크로 설정도 가져오시겠습니까?" + "new_version": { + "title": "소프트웨어 업데이트", + "sub_text_1": "새 버전", + "sub_text_2": "사용 가능!", + "sub_text_3": "지금 다운로드하시겠습니까?", + "ignore_button": "무시", + "remind_later_button": "나중에 알림", + "download_button": "업데이트 다운로드" + }, + "file_menu": { + "file_text": "파일", + "new_text": "새로 만들기", + "load_text": "불러오기", + "save_text": "저장", + "save_as_text": "다른 이름으로 저장" + }, + "options_menu": { + "options_text": "옵션", + "playback_menu": { + "playback_text": "재생", + "speed_text": "속도", + "speed_settings": { + "title": "속도 설정", + "sub_text": "0.1에서 10 사이의 속도 값을 입력하세요", + "error_new_value": "속도 값은 0.1과 10 사이여야 합니다!" + }, + "repeat_text": "반복", + "repeat_settings": { + "title": "반복 설정", + "infinite_repeat": "무한 반복?", + "sub_text": "반복 횟수를 입력하세요", + "error_new_value": "반복 횟수는 최소 1이어야 합니다." + }, + "for_text": "기간", + "for_settings": { + "title": "기간 설정" + }, + "interval_text": "간격", + "interval_settings": { + "title": "간격 설정" + }, + "for_interval_settings": { + "hours_text": "시간", + "minutes_text": "분", + "seconds_text": "초", + "error_new_value_multiple": "입력이 올바르지 않습니다", + "error_new_value_single": "입력이 올바르지 않습니다" }, - "new_version": { - "title": "소프트웨어 업데이트", - "sub_text_1": "새 버전", - "sub_text_2": "사용 가능!", - "sub_text_3": "지금 다운로드하시겠습니까?", - "ignore_button": "무시", - "remind_later_button": "나중에 알림", - "download_button": "업데이트 다운로드" + "scheduled_text": "예약됨", + "scheduled_settings": { + "title": "예약 설정" }, - "file_menu": { - "file_text": "파일", - "new_text": "새로 만들기", - "load_text": "불러오기", - "save_text": "저장", - "save_as_text": "다른 이름으로 저장" + "delay_text": "지연", + "delay_settings": { + "title": "지연 설정", + "sub_text": "반복 간 지연을 입력하세요", + "error_new_value": "지연 시간은 0보다 작을 수 없습니다." }, - "options_menu": { - "options_text": "옵션", - "playback_menu": { - "playback_text": "재생", - "speed_text": "속도", - "speed_settings": { - "title": "속도 설정", - "sub_text": "0.1에서 10 사이의 속도 값을 입력하세요", - "error_new_value": "속도 값은 0.1과 10 사이여야 합니다!" - }, - "repeat_text": "반복", - "repeat_settings": { - "title": "반복 설정", - "infinite_repeat": "무한 반복?", - "sub_text": "반복 횟수를 입력하세요", - "error_new_value": "반복 횟수는 최소 1이어야 합니다." - }, - "for_text": "기간", - "for_settings": { - "title": "기간 설정" - }, - "interval_text": "간격", - "interval_settings": { - "title": "간격 설정" - }, - "for_interval_settings": { - "hours_text": "시간", - "minutes_text": "분", - "seconds_text": "초", - "error_new_value_multiple": "입력이 올바르지 않습니다", - "error_new_value_single": "입력이 올바르지 않습니다" - }, - "scheduled_text": "예약됨", - "scheduled_settings": { - "title": "예약 설정" - }, - "delay_text": "지연", - "delay_settings": { - "title": "지연 설정", - "sub_text": "반복 간 지연을 입력하세요", - "error_new_value": "지연 시간은 0보다 작을 수 없습니다." - } - }, - "recordings_menu": { - "recordings_text": "녹화", - "mouse_movement_text": "마우스 이동", - "mouse_click_text": "마우스 클릭", - "keyboard_text": "키보드", - "show_events_statut": "상태 표시줄에 이벤트 표시" - }, - "json_compact": "매크로 데이터 축약", - "settings_menu": { - "settings_text": "설정", - "always_import_macro_settings": "매크로 설정 항상 가져오기", - "lang_text": "언어", - "lang_settings": { - "title": "언어 설정", - "sub_text": "언어를 선택하세요" - }, - "hotkeys_text": "단축키", - "hotkeys_settings": { - "title": "단축키 설정", - "start_record_text": "녹화 시작", - "stop_record_text": "녹화 중지", - "start_playback_text": "재생 시작", - "stop_playback_text": "재생 중지", - "clear_text": "해제", - "please_key_text": "키를 누르세요", - "error_hotkeys": "녹화 시작과 재생 시작에 동일한 단축키를 지정할 수 없습니다" - }, - "minimization_text": "최소화", - "minimization_toast": "PyMacroRecord가 최소화되었습니다.", - "minimization_menu": { - "minimization_when_playing_text": "재생 중 최소화", - "minimization_when_recording_text": "녹화 중 최소화" - }, - "after_playback_text": "재생 후", - "after_playback_settings": { - "when_playback_complete_text": "재생 완료 시", - "title": "재생 후 설정", - "sub_text": "재생이 끝났을 때", - "idle": "휴기", - "quit_software": "프로그램 종료", - "standby": "대기 모드", - "log_off_computer": "로그오프", - "turn_off_computer": "컴퓨터 종료", - "restart computer": "컴퓨터 재시작", - "hibernate_if_enabled": "최대 절전 모드(활성화된 경우)" - } - }, - "others_menu": { - "others_text": "기타", - "check_update_text": "업데이트 확인", - "reset_settings_text": "설정 재설정", - "reset_settings_confirmation": "설정을 재설정하시겠습니까?", - "fixed_timestamp_text": "고정 타임스탬프", - "fixed_timestamp_settings": { - "title": "고정 타임스탬프 설정", - "sub_text": "고정 타임스탬프를 입력하세요" - } - } + "randomized_delay_text": "무작위 지연", + "randomized_delay_settings": { + "title": "무작위 지연 설정", + "sub_text": "동작 간 무작위 지연 범위를 밀리초 단위로 설정합니다.\n0보다 작은 값은 다음 동작까지의 시간을 줄이고,\n 0보다 큰 값은 다음 동작까지의 시간을 늘립니다.", + "lower_text": "하한:", + "upper_text": "상한:", + "error_new_value": "무작위 지연의 두 범위 모두 유효한 숫자여야 합니다." + } + }, + "recordings_menu": { + "recordings_text": "녹화", + "mouse_movement_text": "마우스 이동", + "mouse_click_text": "마우스 클릭", + "keyboard_text": "키보드", + "show_events_statut": "상태 표시줄에 이벤트 표시" + }, + "json_compact": "매크로 데이터 축약", + "settings_menu": { + "settings_text": "설정", + "always_import_macro_settings": "매크로 설정 항상 가져오기", + "lang_text": "언어", + "lang_settings": { + "title": "언어 설정", + "sub_text": "언어를 선택하세요" + }, + "hotkeys_text": "단축키", + "hotkeys_settings": { + "title": "단축키 설정", + "start_record_text": "녹화 시작", + "stop_record_text": "녹화 중지", + "start_playback_text": "재생 시작", + "stop_playback_text": "재생 중지", + "clear_text": "해제", + "please_key_text": "키를 누르세요", + "error_hotkeys": "녹화 시작과 재생 시작에 동일한 단축키를 지정할 수 없습니다" }, - "help_menu": { - "help_text": "도움말", - "tutorial_text": "튜토리얼", - "about_text": "정보", - "website_text": "웹사이트", - "about_settings": { - "title": "정보", - "publisher_text": "배포자", - "version_text": "버전", - "license_text": "라이선스", - "version_check_update_text": { - "checking": "확인 중...", - "up_to_date": "최신", - "outdated": "구버전", - "disabled": "업데이트 확인 비활성화됨", - "failed": "업데이트 확인에 실패했습니다" - } - } + "minimization_text": "최소화", + "minimization_toast": "PyMacroRecord가 최소화되었습니다.", + "minimization_menu": { + "minimization_when_playing_text": "재생 중 최소화", + "minimization_when_recording_text": "녹화 중 최소화" }, - "others_menu": { - "others_text": "기타", - "donors_text": "기부자", - "donors_settings": { - "title": "기부자", - "sub_text": "모든 기부자", - "load_donors": "기부자 불러오는 중...", - "want_be_donor": "기부자가 되고 싶으신가요? 여기를 클릭하세요!", - "cant_get_donors": "기부자 정보를 가져올 수 없습니다 :(" - }, - "translators_text": "번역자", - "translators_settings": { - "title": "번역자", - "sub_text": "모든 번역자", - "page": "페이지" - } + "after_playback_text": "재생 후", + "after_playback_settings": { + "when_playback_complete_text": "재생 완료 시", + "title": "재생 후 설정", + "sub_text": "재생이 끝났을 때", + "idle": "휴기", + "quit_software": "프로그램 종료", + "standby": "대기 모드", + "log_off_computer": "로그오프", + "turn_off_computer": "컴퓨터 종료", + "restart computer": "컴퓨터 재시작", + "hibernate_if_enabled": "최대 절전 모드(활성화된 경우)" + } + }, + "others_menu": { + "others_text": "기타", + "check_update_text": "업데이트 확인", + "reset_settings_text": "설정 재설정", + "reset_settings_confirmation": "설정을 재설정하시겠습니까?", + "fixed_timestamp_text": "고정 타임스탬프", + "fixed_timestamp_settings": { + "title": "고정 타임스탬프 설정", + "sub_text": "고정 타임스탬프를 입력하세요" } + } + }, + "help_menu": { + "help_text": "도움말", + "tutorial_text": "튜토리얼", + "about_text": "정보", + "website_text": "웹사이트", + "about_settings": { + "title": "정보", + "publisher_text": "배포자", + "version_text": "버전", + "license_text": "라이선스", + "version_check_update_text": { + "checking": "확인 중...", + "up_to_date": "최신", + "outdated": "구버전", + "disabled": "업데이트 확인 비활성화됨", + "failed": "업데이트 확인에 실패했습니다" + } + } + }, + "others_menu": { + "others_text": "기타", + "donors_text": "기부자", + "donors_settings": { + "title": "기부자", + "sub_text": "모든 기부자", + "load_donors": "기부자 불러오는 중...", + "want_be_donor": "기부자가 되고 싶으신가요? 여기를 클릭하세요!", + "cant_get_donors": "기부자 정보를 가져올 수 없습니다 :(" + }, + "translators_text": "번역자", + "translators_settings": { + "title": "번역자", + "sub_text": "모든 번역자", + "page": "페이지" + } } + } } diff --git a/src/langs/nl.json b/src/langs/nl.json index 05b5071..0493ed0 100644 --- a/src/langs/nl.json +++ b/src/langs/nl.json @@ -75,6 +75,14 @@ "title": "Uitstel instellingen", "sub_text": "Geef uitstel tussen herhalingen op", "error_new_value": "Je kan niet minder dan 0 uitstel hebben." + }, + "randomized_delay_text": "Willekeurige vertraging", + "randomized_delay_settings": { + "title": "Instellingen voor willekeurige vertraging", + "sub_text": "Configureer de grenzen van de willekeurige vertraging tussen acties in milliseconden.\nWaarden onder nul zorgen ervoor dat de tijd tot de volgende actie wordt verkort,\n terwijl waarden boven nul ervoor zorgen dat deze tijd wordt verlengd.", + "lower_text": "Ondergrens:", + "upper_text": "Bovengrens:", + "error_new_value": "Beide grenzen van de willekeurige vertraging moeten geldige getallen zijn." } }, "recordings_menu": { diff --git a/src/langs/pt-BR.json b/src/langs/pt-BR.json index a9f224e..637a2fc 100644 --- a/src/langs/pt-BR.json +++ b/src/langs/pt-BR.json @@ -1,176 +1,184 @@ { - "information": { - "author": "takiem", - "lang_short": "pt-BR", - "lang_long": "Brazilian-Portuguese" + "information": { + "author": "takiem", + "lang_short": "pt-BR", + "lang_long": "Brazilian-Portuguese" + }, + "content": { + "global": { + "confirm_button": "Confirmar", + "cancel_button": "Cancelar", + "close_button": "Fechar", + "error": "Erro", + "previous_text": "Anterior", + "next_text": "Próximo", + "confirm_save": "Deseja salvar sua gravação?", + "confirm": "Confirmar", + "information": "Informação", + "restart_software_text": "Você precisa reiniciar o software para que as alterações tenham efeito.", + "load_macro_settings": "Importar também as configurações macro?" }, - "content": { - "global": { - "confirm_button": "Confirmar", - "cancel_button": "Cancelar", - "close_button": "Fechar", - "error": "Erro", - "previous_text": "Anterior", - "next_text": "Próximo", - "confirm_save": "Deseja salvar sua gravação?", - "confirm": "Confirmar", - "information": "Informação", - "restart_software_text": "Você precisa reiniciar o software para que as alterações tenham efeito.", - "load_macro_settings": "Importar também as configurações macro?" - }, - "new_version": { - "title": "Atualização de Software", - "sub_text_1": "Nova versão", - "sub_text_2": "disponível!", - "sub_text_3": "Deseja baixar agora?", - "ignore_button": "Ignorar", - "remind_later_button": "Lembrar mais tarde", - "download_button": "Baixar atualização" - }, - "file_menu": { - "file_text": "Arquivo", - "new_text": "Novo", - "load_text": "Carregar", - "save_text": "Salvar", - "save_as_text": "Salvar como" - }, - "options_menu": { - "options_text": "Opções", - "playback_menu": { - "playback_text": "Reprodução", - "speed_text": "Velocidade", - "speed_settings": { - "title": "Configurações de Velocidade", - "sub_text": "Digite um valor de velocidade entre 0.1 e 10", - "error_new_value": "O valor de velocidade deve estar entre 0.1 e 10!" - }, - "repeat_text": "Repetir", - "repeat_settings": { - "title": "Configurações de Repetição", - "infinite_repeat": "Repetir infinitamente?", - "sub_text": "Digite o número de repetições", - "error_new_value": "O número de repetições não pode ser inferior a 1." - }, - "for_text": "Por", - "for_settings": { - "title": "Configurações de tempo" - }, - "interval_text": "Intervalo", - "interval_settings": { - "title": "Configurações de Intervalo" - }, - "for_interval_settings": { - "hours_text": "Horas", - "minutes_text": "Minutos", - "seconds_text": "Segundos", - "error_new_value_multiple": "entradas estão incorretas", - "error_new_value_single": "entrada está incorreta" - }, - "scheduled_text": "Programado", - "scheduled_settings": { - "title": "Configurações programadas" - }, - "delay_text": "Atraso", - "delay_settings": { - "title": "Configurações de Atraso", - "sub_text": "Digite o atraso entre as repetições", - "error_new_value": "O atraso não pode ser inferior a 0." - } + "new_version": { + "title": "Atualização de Software", + "sub_text_1": "Nova versão", + "sub_text_2": "disponível!", + "sub_text_3": "Deseja baixar agora?", + "ignore_button": "Ignorar", + "remind_later_button": "Lembrar mais tarde", + "download_button": "Baixar atualização" + }, + "file_menu": { + "file_text": "Arquivo", + "new_text": "Novo", + "load_text": "Carregar", + "save_text": "Salvar", + "save_as_text": "Salvar como" + }, + "options_menu": { + "options_text": "Opções", + "playback_menu": { + "playback_text": "Reprodução", + "speed_text": "Velocidade", + "speed_settings": { + "title": "Configurações de Velocidade", + "sub_text": "Digite um valor de velocidade entre 0.1 e 10", + "error_new_value": "O valor de velocidade deve estar entre 0.1 e 10!" }, - "recordings_menu": { - "recordings_text": "Gravações", - "mouse_movement_text": "Movimento do Mouse", - "mouse_click_text": "Clique do Mouse", - "keyboard_text": "Teclado", - "show_events_statut": "Mostrar eventos na barra de status" + "repeat_text": "Repetir", + "repeat_settings": { + "title": "Configurações de Repetição", + "infinite_repeat": "Repetir infinitamente?", + "sub_text": "Digite o número de repetições", + "error_new_value": "O número de repetições não pode ser inferior a 1." }, - "json_compact": "Compactar dados do macro", - "settings_menu": { - "settings_text": "Configurações", - "always_import_macro_settings": "Importeer altijd macro-instellingen", - "lang_text": "Idioma", - "lang_settings": { - "title": "Configurações de Idioma", - "sub_text": "Escolha seu idioma" - }, - "hotkeys_text": "Teclas de Atalho", - "hotkeys_settings": { - "title": "Configurações de Teclas de Atalho", - "start_record_text": "Iniciar gravação", - "stop_record_text": "Parar gravação", - "start_playback_text": "Iniciar reprodução", - "stop_playback_text": "Parar reprodução", - "clear_text": "Limpar", - "please_key_text": "Por favor, pressione uma tecla", - "error_hotkeys": "As teclas de atalho para iniciar a gravação e a reprodução não podem ser as mesmas" - }, - "minimization_text": "Minimizar", - "minimization_toast": "PyMacroRecord foi minimizado.", - "minimization_menu": { - "minimization_when_playing_text": "Minimizar ao reproduzir", - "minimization_when_recording_text": "Minimizar ao gravar" - }, - "after_playback_text": "Após a reprodução", - "after_playback_settings": { - "when_playback_complete_text": "Quando a reprodução for concluída", - "title": "Configurações pós-reprodução", - "sub_text": "Ao concluir a reprodução", - "idle": "Ocioso", - "quit_software": "Fechar software", - "standby": "Suspender", - "log_off_computer": "Encerrar sessão", - "turn_off_computer": "Desligar o computador", - "restart computer": "Reiniciar o computador", - "hibernate_if_enabled": "Hibernar (se habilitado)" - } + "for_text": "Por", + "for_settings": { + "title": "Configurações de tempo" }, - "others_menu": { - "others_text": "Outros", - "check_update_text": "Verificar atualização", - "reset_settings_text": "Redefinir configurações", - "reset_settings_confirmation": "Tem certeza de que deseja redefinir suas configurações?", - "fixed_timestamp_text": "Timestamp fixo", - "fixed_timestamp_settings": { - "title": "Configurações de Timestamp Fixo", - "sub_text": "Digite o timestamp fixo" - } + "interval_text": "Intervalo", + "interval_settings": { + "title": "Configurações de Intervalo" + }, + "for_interval_settings": { + "hours_text": "Horas", + "minutes_text": "Minutos", + "seconds_text": "Segundos", + "error_new_value_multiple": "entradas estão incorretas", + "error_new_value_single": "entrada está incorreta" + }, + "scheduled_text": "Programado", + "scheduled_settings": { + "title": "Configurações programadas" + }, + "delay_text": "Atraso", + "delay_settings": { + "title": "Configurações de Atraso", + "sub_text": "Digite o atraso entre as repetições", + "error_new_value": "O atraso não pode ser inferior a 0." + }, + "randomized_delay_text": "Atraso aleatório", + "randomized_delay_settings": { + "title": "Configurações do atraso aleatório", + "sub_text": "Configure os limites do atraso aleatório entre ações em milissegundos.\nValores abaixo de zero permitirão reduzir o tempo até a próxima ação,\n enquanto valores acima de zero permitirão aumentar o tempo até a próxima ação.", + "lower_text": "Inferior:", + "upper_text": "Superior:", + "error_new_value": "Ambos os limites do atraso aleatório devem ser números válidos." } }, - "help_menu": { - "help_text": "Ajuda", - "tutorial_text": "Tutorial", - "about_text": "Sobre", - "website_text": "Website", - "about_settings": { - "title": "Sobre", - "publisher_text": "Editor", - "version_text": "Versão", - "license_text": "Sob licença", - "version_check_update_text": { - "checking": "Verificando...", - "up_to_date": "Atualizado", - "outdated": "Desatualizado", - "disabled": "Verificação de atualização desativada", - "failed": "Não foi possível verificar se há uma nova atualização" - } + "recordings_menu": { + "recordings_text": "Gravações", + "mouse_movement_text": "Movimento do Mouse", + "mouse_click_text": "Clique do Mouse", + "keyboard_text": "Teclado", + "show_events_statut": "Mostrar eventos na barra de status" + }, + "json_compact": "Compactar dados do macro", + "settings_menu": { + "settings_text": "Configurações", + "always_import_macro_settings": "Importeer altijd macro-instellingen", + "lang_text": "Idioma", + "lang_settings": { + "title": "Configurações de Idioma", + "sub_text": "Escolha seu idioma" + }, + "hotkeys_text": "Teclas de Atalho", + "hotkeys_settings": { + "title": "Configurações de Teclas de Atalho", + "start_record_text": "Iniciar gravação", + "stop_record_text": "Parar gravação", + "start_playback_text": "Iniciar reprodução", + "stop_playback_text": "Parar reprodução", + "clear_text": "Limpar", + "please_key_text": "Por favor, pressione uma tecla", + "error_hotkeys": "As teclas de atalho para iniciar a gravação e a reprodução não podem ser as mesmas" + }, + "minimization_text": "Minimizar", + "minimization_toast": "PyMacroRecord foi minimizado.", + "minimization_menu": { + "minimization_when_playing_text": "Minimizar ao reproduzir", + "minimization_when_recording_text": "Minimizar ao gravar" + }, + "after_playback_text": "Após a reprodução", + "after_playback_settings": { + "when_playback_complete_text": "Quando a reprodução for concluída", + "title": "Configurações pós-reprodução", + "sub_text": "Ao concluir a reprodução", + "idle": "Ocioso", + "quit_software": "Fechar software", + "standby": "Suspender", + "log_off_computer": "Encerrar sessão", + "turn_off_computer": "Desligar o computador", + "restart computer": "Reiniciar o computador", + "hibernate_if_enabled": "Hibernar (se habilitado)" } }, "others_menu": { "others_text": "Outros", - "donors_text": "Doadores", - "donors_settings": { - "title": "Doadores", - "sub_text": "Todos os doadores", - "load_donors": "Carregando doadores...", - "want_be_donor": "Quer ser um doador? Clique aqui!", - "cant_get_donors": "Não foi possível obter os doadores :(" - }, - "translators_text": "Tradutores", - "translators_settings": { - "title": "Tradutores", - "sub_text": "Todos os tradutores", - "page": "Página" + "check_update_text": "Verificar atualização", + "reset_settings_text": "Redefinir configurações", + "reset_settings_confirmation": "Tem certeza de que deseja redefinir suas configurações?", + "fixed_timestamp_text": "Timestamp fixo", + "fixed_timestamp_settings": { + "title": "Configurações de Timestamp Fixo", + "sub_text": "Digite o timestamp fixo" + } + } + }, + "help_menu": { + "help_text": "Ajuda", + "tutorial_text": "Tutorial", + "about_text": "Sobre", + "website_text": "Website", + "about_settings": { + "title": "Sobre", + "publisher_text": "Editor", + "version_text": "Versão", + "license_text": "Sob licença", + "version_check_update_text": { + "checking": "Verificando...", + "up_to_date": "Atualizado", + "outdated": "Desatualizado", + "disabled": "Verificação de atualização desativada", + "failed": "Não foi possível verificar se há uma nova atualização" } } + }, + "others_menu": { + "others_text": "Outros", + "donors_text": "Doadores", + "donors_settings": { + "title": "Doadores", + "sub_text": "Todos os doadores", + "load_donors": "Carregando doadores...", + "want_be_donor": "Quer ser um doador? Clique aqui!", + "cant_get_donors": "Não foi possível obter os doadores :(" + }, + "translators_text": "Tradutores", + "translators_settings": { + "title": "Tradutores", + "sub_text": "Todos os tradutores", + "page": "Página" + } } } +} diff --git a/src/langs/ru-RU.json b/src/langs/ru-RU.json index 6d3c6dd..c804197 100644 --- a/src/langs/ru-RU.json +++ b/src/langs/ru-RU.json @@ -75,6 +75,14 @@ "title": "Настройки задержки", "sub_text": "Введите задержку между повторами", "error_new_value": "Задержка не может быть меньше 0." + }, + "randomized_delay_text": "Случайная задержка", + "randomized_delay_settings": { + "title": "Настройки случайной задержки", + "sub_text": "Настройте границы случайной задержки между действиями в миллисекундах.\nЗначения ниже нуля позволят уменьшить время до следующего действия,\n а значения выше нуля позволят увеличить время до следующего действия.", + "lower_text": "Нижняя граница:", + "upper_text": "Верхняя граница:", + "error_new_value": "Обе границы случайной задержки должны быть допустимыми числами." } }, "recordings_menu": { diff --git a/src/langs/tr.json b/src/langs/tr.json index 8c9f524..a855438 100644 --- a/src/langs/tr.json +++ b/src/langs/tr.json @@ -76,6 +76,14 @@ "title": "Gecikme ayarları", "sub_text": "Tekrarlama arasındaki gecikmeyi girin.", "error_new_value": "Gecikme süresi en az 0 olabilir" + }, + "randomized_delay_text": "Rastgele gecikme", + "randomized_delay_settings": { + "title": "Rastgele gecikme ayarları", + "sub_text": "Eylemler arasındaki rastgele gecikme sınırlarını milisaniye cinsinden yapılandırın.\nSıfırın altındaki değerler bir sonraki eyleme kadar olan sürenin azalmasını,\n sıfırın üzerindeki değerler ise bu sürenin artmasını sağlar.", + "lower_text": "Alt:", + "upper_text": "Üst:", + "error_new_value": "Rastgele gecikmenin her iki sınırı da geçerli sayılar olmalıdır." } }, "recordings_menu": { @@ -168,9 +176,9 @@ }, "translators_text": "Çevirmenler", "translators_settings": { - "title": "Çevirmenler", - "sub_text": "Tüm çevirmenler", - "page": "Sayfa" + "title": "Çevirmenler", + "sub_text": "Tüm çevirmenler", + "page": "Sayfa" } } } diff --git a/src/langs/zh-CN.json b/src/langs/zh-CN.json index d545843..46904ab 100644 --- a/src/langs/zh-CN.json +++ b/src/langs/zh-CN.json @@ -75,6 +75,14 @@ "title": "延迟设置", "sub_text": "输入每次重复回放之间的延迟", "error_new_value": "延迟必须大于0." + }, + "randomized_delay_text": "随机延迟", + "randomized_delay_settings": { + "title": "随机延迟设置", + "sub_text": "以毫秒为单位配置操作之间的随机延迟范围。\n小于零的值将缩短执行下一操作前的时间,\n 大于零的值将延长执行下一操作前的时间", + "lower_text": "下限:", + "upper_text": "上限:", + "error_new_value": "随机延迟的两个范围都必须是有效数字。" } }, "recordings_menu": { @@ -167,9 +175,9 @@ }, "translators_text": "翻译者", "translators_settings": { - "title": "翻译者", - "sub_text": "所有翻译者", - "page": "第 页" + "title": "翻译者", + "sub_text": "所有翻译者", + "page": "第 页" } } } From 89724b03f5091b19d36a30382e2d738e3c2e63bd Mon Sep 17 00:00:00 2001 From: Noah-Jaffe Date: Sat, 12 Sep 2026 10:45:42 -0400 Subject: [PATCH 03/16] add supporting framework for randomized delay --- src/utils/user_settings.py | 19 +++++++ src/windows/main/menu_bar.py | 3 +- src/windows/options/playback/__init__.py | 1 + .../options/playback/randomized_delay.py | 54 +++++++++++++++++++ 4 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 src/windows/options/playback/randomized_delay.py diff --git a/src/utils/user_settings.py b/src/utils/user_settings.py index be7103d..f503c0a 100644 --- a/src/utils/user_settings.py +++ b/src/utils/user_settings.py @@ -38,6 +38,11 @@ def init_settings(self): userSettings = { "Playback": { "Speed": 1, + "Randomized_Delay": { + "Enabled": False, + "Lower": 0, + "Upper": 0, + }, "Repeat": { "Times": 1, "For": 0, @@ -147,6 +152,20 @@ def check_new_options(self): userSettings["Others"] = {"Check_update": True} if "Delay" not in userSettings["Playback"]["Repeat"]: userSettings["Playback"]["Repeat"]["Delay"] = 0 + if "Randomized_Delay" not in userSettings["Playback"]: + userSettings["Playback"]["Randomized_Delay"] = { + "Enabled": False, + "Lower": 0, + "Upper": 0, + } + else: + randomized_delay = userSettings["Playback"]["Randomized_Delay"] + if "Enabled" not in randomized_delay: + randomized_delay["Enabled"] = (randomized_delay.get("Lower", 0) != 0 or randomized_delay.get("Upper", 0) != 0) + if "Lower" not in randomized_delay: + randomized_delay["Lower"] = 0 + if "Upper" not in randomized_delay: + randomized_delay["Upper"] = 0 if "Remind_new_ver_at" not in userSettings["Others"]: userSettings["Others"]["Remind_new_ver_at"] = 0 if "Language" not in userSettings: diff --git a/src/windows/main/menu_bar.py b/src/windows/main/menu_bar.py index a42a968..77dafcf 100644 --- a/src/windows/main/menu_bar.py +++ b/src/windows/main/menu_bar.py @@ -4,7 +4,7 @@ from utils.record_file_management import RecordFileManagement from windows.help.about import About -from windows.options.playback import Delay, Repeat, Speed, TimeGui +from windows.options.playback import Delay, RandomizedDelay, Repeat, Speed, TimeGui from windows.options.settings import AfterPlayBack, Hotkeys, SelectLanguage from windows.others.donors import Donors from windows.others.translators import Translators @@ -50,6 +50,7 @@ def __init__(self, parent): playback_sub.add_command(label=self.text_config["options_menu"]["playback_menu"]["for_text"], command=lambda: TimeGui(self, parent, "For")) playback_sub.add_command(label=self.text_config["options_menu"]["playback_menu"]["scheduled_text"], command=lambda: TimeGui(self, parent, "Scheduled")) playback_sub.add_command(label=self.text_config["options_menu"]["playback_menu"]["delay_text"], command=lambda: Delay(self, parent)) + playback_sub.add_command(label=self.text_config["options_menu"]["playback_menu"]["randomized_delay_text"], command=lambda: RandomizedDelay(self, parent)) # Recordings Sub self.mouseMove = BooleanVar(value=userSettings["Recordings"]["Mouse_Move"]) diff --git a/src/windows/options/playback/__init__.py b/src/windows/options/playback/__init__.py index 3592f06..24365c3 100644 --- a/src/windows/options/playback/__init__.py +++ b/src/windows/options/playback/__init__.py @@ -2,3 +2,4 @@ from .repeat import Repeat from .speed import Speed from .time_gui import TimeGui +from .randomized_delay import RandomizedDelay diff --git a/src/windows/options/playback/randomized_delay.py b/src/windows/options/playback/randomized_delay.py new file mode 100644 index 0000000..72fd0cf --- /dev/null +++ b/src/windows/options/playback/randomized_delay.py @@ -0,0 +1,54 @@ +from tkinter import BOTTOM, LEFT, TOP, Spinbox, messagebox +from tkinter.ttk import Button, Frame, Label +from sys import maxsize as INT_BOUND +from windows.popup import Popup + + +class RandomizedDelay(Popup): + def __init__(self, parent, main_app): + super().__init__(main_app.text_content["options_menu"]["playback_menu"]["randomized_delay_settings"]["title"], 350, 180, parent) + main_app.prevent_record = True + self.settings = main_app.settings + Label(self, text=main_app.text_content["options_menu"]["playback_menu"]["randomized_delay_settings"]["sub_text"], font=("Segoe UI", 10)).pack(side=TOP, pady=10) + userSettings = main_app.settings.settings_dict + randomized_delay = userSettings["Playback"].get("Randomized_Delay",{"Enabled": False, "Lower": 0, "Upper": 0}) + inputArea = Frame(self) + Label(inputArea, text=main_app.text_content["options_menu"]["playback_menu"]["randomized_delay_settings"]["lower_text"]).pack(side=LEFT, padx=5) + lowerInput = Spinbox(inputArea, from_=-1*INT_BOUND, to=INT_BOUND, width=9, validate="key", validatecommand=(main_app.validate_cmd_float, "%d", "%P")) + lowerInput.delete(0, "end") + lowerInput.insert(0, str(randomized_delay.get("Lower", 0))) + lowerInput.pack(side=LEFT, padx=5) + Label(inputArea, text=main_app.text_content["options_menu"]["playback_menu"]["randomized_delay_settings"]["upper_text"]).pack(side=LEFT, padx=5) + upperInput = Spinbox(inputArea, from_=-1*INT_BOUND, to=INT_BOUND, width=9, validate="key", validatecommand=(main_app.validate_cmd_float, "%d", "%P")) + upperInput.delete(0, "end") + upperInput.insert(0, str(randomized_delay.get("Upper", 0))) + upperInput.pack(side=LEFT, padx=5) + inputArea.pack(pady=10) + buttonArea = Frame(self) + Button(buttonArea, text=main_app.text_content["global"]["confirm_button"], command=lambda: self.setNewValues(lowerInput.get(), upperInput.get(), main_app)).pack(side=LEFT, padx=10) + Button(buttonArea, text=main_app.text_content["global"]["cancel_button"], command=self.destroy).pack(side=LEFT, padx=10) + buttonArea.pack(side=BOTTOM, pady=10) + self.update_idletasks() + + popup_width = min(max(350, self.winfo_reqwidth() + 10), 800) + popup_height = min(max(180, self.winfo_reqheight() + 10), 600) + self.geometry(f"{popup_width}x{popup_height}") + self.wait_window() + main_app.prevent_record = False + + def setNewValues(self, lower_bound, upper_bound, main_app): + """Function to set the new Randomized Delay numbers""" + try: + lower_bound = float(lower_bound) + upper_bound = float(upper_bound) + except ValueError: + messagebox.showerror( + main_app.text_content["global"]["error"], + main_app.text_content["options_menu"]["playback_menu"]["randomized_delay_settings"]["error_new_value"], + ) + return + if lower_bound > upper_bound: + lower_bound, upper_bound = upper_bound, lower_bound + enabled = lower_bound != 0 or upper_bound != 0 + self.settings.change_settings("Playback", "Randomized_Delay", None, {"Enabled": enabled, "Lower": lower_bound, "Upper": upper_bound}) + self.destroy() \ No newline at end of file From b0b399246be2b0da9b7caa7e6db2258e243da94d Mon Sep 17 00:00:00 2001 From: Noah-Jaffe Date: Sat, 12 Sep 2026 11:24:19 -0400 Subject: [PATCH 04/16] update sub_text for randomized delay settings --- src/langs/bg.json | 2 +- src/langs/de.json | 2 +- src/langs/en.json | 2 +- src/langs/eo.json | 2 +- src/langs/es.json | 2 +- src/langs/fr.json | 2 +- src/langs/it.json | 2 +- src/langs/ko.json | 2 +- src/langs/nl.json | 2 +- src/langs/pt-BR.json | 2 +- src/langs/ru-RU.json | 2 +- src/langs/tr.json | 2 +- src/langs/zh-CN.json | 2 +- 13 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/langs/bg.json b/src/langs/bg.json index 7b29f97..0cf84a6 100644 --- a/src/langs/bg.json +++ b/src/langs/bg.json @@ -79,7 +79,7 @@ "randomized_delay_text": "Случайно забавяне", "randomized_delay_settings": { "title": "Настройки за случайно забавяне", - "sub_text": "Конфигурирайте границите на случайното забавяне между действията в милисекунди.\nСтойности под нулата ще позволят намаляване на времето до следващото действие,\n а стойности над нулата ще позволят увеличаване на времето до следващото действие.", + "sub_text": "Конфигурирайте границите на случайното забавяне между действията в милисекунди.\nСтойности под нулата ще намалят времето до следващото действие.\nСтойности над нулата ще увеличат времето до следващото действие.", "lower_text": "Долна граница:", "upper_text": "Горна граница:", "error_new_value": "И двете граници на случайното забавяне трябва да бъдат валидни числа." diff --git a/src/langs/de.json b/src/langs/de.json index 0c1e214..71ad321 100644 --- a/src/langs/de.json +++ b/src/langs/de.json @@ -79,7 +79,7 @@ "randomized_delay_text": "Zufällige Verzögerung", "randomized_delay_settings": { "title": "Einstellungen für zufällige Verzögerung", - "sub_text": "Konfigurieren Sie die Grenzen der zufälligen Verzögerung zwischen Aktionen in Millisekunden.\nWerte unter null ermöglichen eine Verkürzung der Zeit bis zur nächsten Aktion,\n während Werte über null eine Verlängerung der Zeit bis zur nächsten Aktion ermöglichen.", + "sub_text": "Konfigurieren Sie die Grenzen der zufälligen Verzögerung zwischen Aktionen in Millisekunden.\nWerte unter null verkürzen die Zeit bis zur nächsten Aktion.\nWerte über null verlängern die Zeit bis zur nächsten Aktion.", "lower_text": "Untergrenze:", "upper_text": "Obergrenze:", "error_new_value": "Beide Grenzen der zufälligen Verzögerung müssen gültige Zahlen sein." diff --git a/src/langs/en.json b/src/langs/en.json index ee72ed2..ca3a3d3 100644 --- a/src/langs/en.json +++ b/src/langs/en.json @@ -79,7 +79,7 @@ "randomized_delay_text": "Randomized delay", "randomized_delay_settings": { "title": "Randomized delay settings", - "sub_text": "Configure randomized delay bounds between actions in milliseconds.\nValues below zero will allow for decreased time to next action,\n and above zero will allow for increased time to next action.", + "sub_text": "Configure randomized delay bounds between actions in milliseconds.\nValues below zero will allow for decreased time to next action.\nValues above zero will allow for increased time to next action.", "lower_text": "Lower:", "upper_text": "Upper:", "error_new_value": "Both randomized delay bounds must be valid numbers." diff --git a/src/langs/eo.json b/src/langs/eo.json index f10247c..48cef03 100644 --- a/src/langs/eo.json +++ b/src/langs/eo.json @@ -79,7 +79,7 @@ "randomized_delay_text": "Hazarda prokrasto", "randomized_delay_settings": { "title": "Agordoj de hazarda prokrasto", - "sub_text": "Agordu la limojn de la hazarda prokrasto inter agoj en milisekundoj.\nValoroj sub nul permesos malpli da tempo ĝis la sekva ago,\n kaj valoroj super nul permesos pli da tempo ĝis la sekva ago.", + "sub_text": "Agordu la limojn de la hazarda prokrasto inter agoj en milisekundoj.\nValoroj sub nul reduktos la tempon ĝis la sekva ago.\nValoroj super nul pliigos la tempon ĝis la sekva ago.", "lower_text": "Malsupra:", "upper_text": "Supra:", "error_new_value": "Ambaŭ limoj de la hazarda prokrasto devas esti validaj nombroj." diff --git a/src/langs/es.json b/src/langs/es.json index 108eb5b..1681d54 100644 --- a/src/langs/es.json +++ b/src/langs/es.json @@ -79,7 +79,7 @@ "randomized_delay_text": "Retraso aleatorio", "randomized_delay_settings": { "title": "Configuración del retraso aleatorio", - "sub_text": "Configure los límites del retraso aleatorio entre acciones en milisegundos.\nLos valores inferiores a cero permitirán reducir el tiempo hasta la siguiente acción,\n mientras que los valores superiores a cero permitirán aumentar el tiempo hasta la siguiente acción.", + "sub_text": "Configure los límites del retraso aleatorio entre acciones en milisegundos.\nLos valores inferiores a cero reducirán el tiempo hasta la siguiente acción.\nLos valores superiores a cero aumentarán el tiempo hasta la siguiente acción.", "lower_text": "Inferior:", "upper_text": "Superior:", "error_new_value": "Ambos límites del retraso aleatorio deben ser números válidos." diff --git a/src/langs/fr.json b/src/langs/fr.json index 05d4b0e..9141eff 100644 --- a/src/langs/fr.json +++ b/src/langs/fr.json @@ -79,7 +79,7 @@ "randomized_delay_text": "Délai aléatoire", "randomized_delay_settings": { "title": "Paramètres du délai aléatoire", - "sub_text": "Configurez les limites du délai aléatoire entre les actions en millisecondes.\nLes valeurs inférieures à zéro permettront de réduire le temps avant l'action suivante,\n tandis que les valeurs supérieures à zéro permettront d'augmenter le temps avant l'action suivante.", + "sub_text": "Configurez les limites du délai aléatoire entre les actions en millisecondes.\nLes valeurs inférieures à zéro réduiront le temps avant l'action suivante.\nLes valeurs supérieures à zéro augmenteront le temps avant l'action suivante.", "lower_text": "Inférieure :", "upper_text": "Supérieure :", "error_new_value": "Les deux limites du délai aléatoire doivent être des nombres valides." diff --git a/src/langs/it.json b/src/langs/it.json index baf075a..f48d927 100644 --- a/src/langs/it.json +++ b/src/langs/it.json @@ -79,7 +79,7 @@ "randomized_delay_text": "Ritardo casuale", "randomized_delay_settings": { "title": "Impostazioni del ritardo casuale", - "sub_text": "Configura i limiti del ritardo casuale tra le azioni in millisecondi.\nI valori inferiori a zero consentiranno di ridurre il tempo fino all'azione successiva,\n mentre i valori superiori a zero consentiranno di aumentare il tempo fino all'azione successiva.", + "sub_text": "Configura i limiti del ritardo casuale tra le azioni in millisecondi.\nI valori inferiori a zero ridurranno il tempo fino all'azione successiva.\nI valori superiori a zero aumenteranno il tempo fino all'azione successiva.", "lower_text": "Inferiore:", "upper_text": "Superiore:", "error_new_value": "Entrambi i limiti del ritardo casuale devono essere numeri validi." diff --git a/src/langs/ko.json b/src/langs/ko.json index 33f9967..b0bd74f 100644 --- a/src/langs/ko.json +++ b/src/langs/ko.json @@ -79,7 +79,7 @@ "randomized_delay_text": "무작위 지연", "randomized_delay_settings": { "title": "무작위 지연 설정", - "sub_text": "동작 간 무작위 지연 범위를 밀리초 단위로 설정합니다.\n0보다 작은 값은 다음 동작까지의 시간을 줄이고,\n 0보다 큰 값은 다음 동작까지의 시간을 늘립니다.", + "sub_text": "동작 간 무작위 지연 범위를 밀리초 단위로 설정합니다.\n0보다 작은 값은 다음 동작까지의 시간을 줄입니다.\n0보다 큰 값은 다음 동작까지의 시간을 늘립니다.", "lower_text": "하한:", "upper_text": "상한:", "error_new_value": "무작위 지연의 두 범위 모두 유효한 숫자여야 합니다." diff --git a/src/langs/nl.json b/src/langs/nl.json index 0493ed0..2f6e160 100644 --- a/src/langs/nl.json +++ b/src/langs/nl.json @@ -79,7 +79,7 @@ "randomized_delay_text": "Willekeurige vertraging", "randomized_delay_settings": { "title": "Instellingen voor willekeurige vertraging", - "sub_text": "Configureer de grenzen van de willekeurige vertraging tussen acties in milliseconden.\nWaarden onder nul zorgen ervoor dat de tijd tot de volgende actie wordt verkort,\n terwijl waarden boven nul ervoor zorgen dat deze tijd wordt verlengd.", + "sub_text": "Configureer de grenzen van de willekeurige vertraging tussen acties in milliseconden.\nWaarden onder nul verkorten de tijd tot de volgende actie.\nWaarden boven nul verlengen de tijd tot de volgende actie.", "lower_text": "Ondergrens:", "upper_text": "Bovengrens:", "error_new_value": "Beide grenzen van de willekeurige vertraging moeten geldige getallen zijn." diff --git a/src/langs/pt-BR.json b/src/langs/pt-BR.json index 637a2fc..42b4e71 100644 --- a/src/langs/pt-BR.json +++ b/src/langs/pt-BR.json @@ -79,7 +79,7 @@ "randomized_delay_text": "Atraso aleatório", "randomized_delay_settings": { "title": "Configurações do atraso aleatório", - "sub_text": "Configure os limites do atraso aleatório entre ações em milissegundos.\nValores abaixo de zero permitirão reduzir o tempo até a próxima ação,\n enquanto valores acima de zero permitirão aumentar o tempo até a próxima ação.", + "sub_text": "Configure os limites do atraso aleatório entre ações em milissegundos.\nValores abaixo de zero reduzirão o tempo até a próxima ação.\nValores acima de zero aumentarão o tempo até a próxima ação.", "lower_text": "Inferior:", "upper_text": "Superior:", "error_new_value": "Ambos os limites do atraso aleatório devem ser números válidos." diff --git a/src/langs/ru-RU.json b/src/langs/ru-RU.json index c804197..27425c1 100644 --- a/src/langs/ru-RU.json +++ b/src/langs/ru-RU.json @@ -79,7 +79,7 @@ "randomized_delay_text": "Случайная задержка", "randomized_delay_settings": { "title": "Настройки случайной задержки", - "sub_text": "Настройте границы случайной задержки между действиями в миллисекундах.\nЗначения ниже нуля позволят уменьшить время до следующего действия,\n а значения выше нуля позволят увеличить время до следующего действия.", + "sub_text": "Настройте границы случайной задержки между действиями в миллисекундах.\nЗначения ниже нуля уменьшат время до следующего действия.\nЗначения выше нуля увеличат время до следующего действия.", "lower_text": "Нижняя граница:", "upper_text": "Верхняя граница:", "error_new_value": "Обе границы случайной задержки должны быть допустимыми числами." diff --git a/src/langs/tr.json b/src/langs/tr.json index a855438..62c3675 100644 --- a/src/langs/tr.json +++ b/src/langs/tr.json @@ -80,7 +80,7 @@ "randomized_delay_text": "Rastgele gecikme", "randomized_delay_settings": { "title": "Rastgele gecikme ayarları", - "sub_text": "Eylemler arasındaki rastgele gecikme sınırlarını milisaniye cinsinden yapılandırın.\nSıfırın altındaki değerler bir sonraki eyleme kadar olan sürenin azalmasını,\n sıfırın üzerindeki değerler ise bu sürenin artmasını sağlar.", + "sub_text": "Eylemler arasındaki rastgele gecikme sınırlarını milisaniye cinsinden yapılandırın.\nSıfırın altındaki değerler bir sonraki eyleme kadar olan süreyi azaltır.\nSıfırın üzerindeki değerler bir sonraki eyleme kadar olan süreyi artırır.", "lower_text": "Alt:", "upper_text": "Üst:", "error_new_value": "Rastgele gecikmenin her iki sınırı da geçerli sayılar olmalıdır." diff --git a/src/langs/zh-CN.json b/src/langs/zh-CN.json index 46904ab..3511076 100644 --- a/src/langs/zh-CN.json +++ b/src/langs/zh-CN.json @@ -79,7 +79,7 @@ "randomized_delay_text": "随机延迟", "randomized_delay_settings": { "title": "随机延迟设置", - "sub_text": "以毫秒为单位配置操作之间的随机延迟范围。\n小于零的值将缩短执行下一操作前的时间,\n 大于零的值将延长执行下一操作前的时间", + "sub_text": "以毫秒为单位配置操作之间的随机延迟范围。\n小于零的值将缩短执行下一操作前的时间。\n大于零的值将延长执行下一操作前的时间。", "lower_text": "下限:", "upper_text": "上限:", "error_new_value": "随机延迟的两个范围都必须是有效数字。" From 6badc8664cb4dd4c6236d3f3a5ee7fd45f7b17c8 Mon Sep 17 00:00:00 2001 From: Noah-Jaffe Date: Sat, 12 Sep 2026 11:36:07 -0400 Subject: [PATCH 05/16] update instructions for speed setting to explain it better (AI TRANSLATIONS FROM ENGLISH) --- src/langs/bg.json | 2 +- src/langs/de.json | 2 +- src/langs/en.json | 2 +- src/langs/eo.json | 2 +- src/langs/es.json | 2 +- src/langs/fr.json | 2 +- src/langs/it.json | 2 +- src/langs/ko.json | 2 +- src/langs/nl.json | 2 +- src/langs/pt-BR.json | 2 +- src/langs/ru-RU.json | 2 +- src/langs/tr.json | 2 +- src/langs/zh-CN.json | 2 +- 13 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/langs/bg.json b/src/langs/bg.json index 0cf84a6..b7f76ce 100644 --- a/src/langs/bg.json +++ b/src/langs/bg.json @@ -41,7 +41,7 @@ "speed_text": "Скорост", "speed_settings": { "title": "Настройки на скоростта", - "sub_text": "Въведете число за скорост между 0.1 и 10", + "sub_text": "Въведете число за скорост между 0,1 и 10\nНастройката за скорост ще влияе само на интервала от време между действията,\nняма да влияе на други настройки, свързани с времето.", "error_new_value": "Стойността на скоростта трябва да бъде между 0.1 и 10!" }, "repeat_text": "Повтаряне", diff --git a/src/langs/de.json b/src/langs/de.json index 71ad321..1c7c8a9 100644 --- a/src/langs/de.json +++ b/src/langs/de.json @@ -41,7 +41,7 @@ "speed_text": "Geschwindigkeit", "speed_settings": { "title": "Geschwindigkeit", - "sub_text": "Wählen Sie eine Geschwindigkeit zwischen 0.1 und 10", + "sub_text": "Wählen Sie eine Geschwindigkeit zwischen 0,1 und 10.\nDie Geschwindigkeitseinstellung wirkt sich nur auf das Zeitintervall zwischen den Aktionen aus,\nsie beeinflusst keine anderen zeitbezogenen Einstellungen.", "error_new_value": "Die Geschwindigkeit muss zwischen 0.1 und 10 liegen!" }, "repeat_text": "Wiederholen", diff --git a/src/langs/en.json b/src/langs/en.json index ca3a3d3..6b88ec3 100644 --- a/src/langs/en.json +++ b/src/langs/en.json @@ -41,7 +41,7 @@ "speed_text": "Speed", "speed_settings": { "title": "Speed settings", - "sub_text": "Enter speed number between 0.1 and 10", + "sub_text": "Enter speed number between 0.1 and 10\nThe speed setting will only affect the time interval between actions,\nthis will not affect other settings that are time related.", "error_new_value": "Your speed value must be between 0.1 and 10!" }, "repeat_text": "Repeat", diff --git a/src/langs/eo.json b/src/langs/eo.json index 48cef03..2d128e2 100644 --- a/src/langs/eo.json +++ b/src/langs/eo.json @@ -41,7 +41,7 @@ "speed_text": "Rapideco", "speed_settings": { "title": "Rapid-agordoj", - "sub_text": "Enmetu rapidecon inter 0.1 kaj 10", + "sub_text": "Enmetu rapidecon inter 0,1 kaj 10\nLa rapideca agordo influos nur la tempintervalon inter agoj,\nĝi ne influos aliajn agordojn rilatajn al tempo.", "error_new_value": "Via rapideco devas esti inter 0.1 kaj 10!" }, "repeat_text": "Ripeto", diff --git a/src/langs/es.json b/src/langs/es.json index 1681d54..92904dd 100644 --- a/src/langs/es.json +++ b/src/langs/es.json @@ -41,7 +41,7 @@ "speed_text": "Velocidad", "speed_settings": { "title": "Ajustes de velocidad", - "sub_text": "Introduzca el número de velocidad entre 0.1 y 10", + "sub_text": "Introduzca el número de velocidad entre 0,1 y 10\nLa configuración de velocidad solo afectará al intervalo de tiempo entre acciones,\nesto no afectará a otras configuraciones relacionadas con el tiempo.", "error_new_value": "¡Su valor de velocidad debe estar entre 0.1 y 10!" }, "repeat_text": "Repetir", diff --git a/src/langs/fr.json b/src/langs/fr.json index 9141eff..f36d871 100644 --- a/src/langs/fr.json +++ b/src/langs/fr.json @@ -41,7 +41,7 @@ "speed_text": "Vitesse", "speed_settings": { "title": "Réglagle de la vitesse", - "sub_text": "Entrez un nombre entre 0.1 et 10", + "sub_text": "Entrez un nombre de vitesse compris entre 0,1 et 10\nLe réglage de la vitesse affectera uniquement l'intervalle de temps entre les actions,\nil n'affectera pas les autres paramètres liés au temps.", "error_new_value": "La valeur de votre vitesse doit être entre 0,1 et 10 !" }, "repeat_text": "Répéter", diff --git a/src/langs/it.json b/src/langs/it.json index f48d927..7138444 100644 --- a/src/langs/it.json +++ b/src/langs/it.json @@ -41,7 +41,7 @@ "speed_text": "Velocità", "speed_settings": { "title": "Impostazioni della velocità", - "sub_text": "Inserisci un numero di velocità tra 0,1 e 10", + "sub_text": "Inserisci un numero di velocità compreso tra 0,1 e 10\nL'impostazione della velocità influirà solo sull'intervallo di tempo tra le azioni,\nnon influirà sulle altre impostazioni relative al tempo.", "error_new_value": "Il valore della velocità deve essere compreso tra 0,1 e 10!" }, "repeat_text": "Ripeti", diff --git a/src/langs/ko.json b/src/langs/ko.json index b0bd74f..2ab96b3 100644 --- a/src/langs/ko.json +++ b/src/langs/ko.json @@ -41,7 +41,7 @@ "speed_text": "속도", "speed_settings": { "title": "속도 설정", - "sub_text": "0.1에서 10 사이의 속도 값을 입력하세요", + "sub_text": "0.1에서 10 사이의 속도 값을 입력하세요.\n속도 설정은 동작 사이의 시간 간격에만 영향을 줍니다.\n시간과 관련된 다른 설정에는 영향을 주지 않습니다.", "error_new_value": "속도 값은 0.1과 10 사이여야 합니다!" }, "repeat_text": "반복", diff --git a/src/langs/nl.json b/src/langs/nl.json index 2f6e160..1f4e0d9 100644 --- a/src/langs/nl.json +++ b/src/langs/nl.json @@ -41,7 +41,7 @@ "speed_text": "Snelheid", "speed_settings": { "title": "Snelheidsinstellingen", - "sub_text": "Geef snelheid tussen 0.1 and 10 in", + "sub_text": "Voer een snelheidsgetal tussen 0,1 en 10 in\nDe snelheidsinstelling heeft alleen invloed op het tijdsinterval tussen acties,\ndit heeft geen invloed op andere tijdgerelateerde instellingen.", "error_new_value": "De snelheid moet tussen 0.1 en 10 zijn!" }, "repeat_text": "Herhaal", diff --git a/src/langs/pt-BR.json b/src/langs/pt-BR.json index 42b4e71..2d2ebe7 100644 --- a/src/langs/pt-BR.json +++ b/src/langs/pt-BR.json @@ -41,7 +41,7 @@ "speed_text": "Velocidade", "speed_settings": { "title": "Configurações de Velocidade", - "sub_text": "Digite um valor de velocidade entre 0.1 e 10", + "sub_text": "Digite um valor de velocidade entre 0,1 e 10\nA configuração de velocidade afetará apenas o intervalo de tempo entre as ações,\nisso não afetará outras configurações relacionadas ao tempo.", "error_new_value": "O valor de velocidade deve estar entre 0.1 e 10!" }, "repeat_text": "Repetir", diff --git a/src/langs/ru-RU.json b/src/langs/ru-RU.json index 27425c1..13bb6b7 100644 --- a/src/langs/ru-RU.json +++ b/src/langs/ru-RU.json @@ -41,7 +41,7 @@ "speed_text": "Скорость", "speed_settings": { "title": "Настройки скорости", - "sub_text": "Введите скорость от 0.1 до 10", + "sub_text": "Введите значение скорости от 0,1 до 10\nНастройка скорости будет влиять только на интервал времени между действиями,\nона не повлияет на другие настройки, связанные со временем.", "error_new_value": "Значение скорости должно быть между 0.1 и 10!" }, "repeat_text": "Повтор", diff --git a/src/langs/tr.json b/src/langs/tr.json index 62c3675..941b2b5 100644 --- a/src/langs/tr.json +++ b/src/langs/tr.json @@ -42,7 +42,7 @@ "speed_text": "Hız", "speed_settings": { "title": "Hız ayarları", - "sub_text": "Hız değerini 0,1 ile 10 arasında girin", + "sub_text": "0,1 ile 10 arasında bir hız değeri girin\nHız ayarı yalnızca eylemler arasındaki zaman aralığını etkiler,\nzamanla ilgili diğer ayarları etkilemez.", "error_new_value": "Hız değeriniz 0,1 ile 10 arasında olmalıdır!" }, "repeat_text": "Tekrarla", diff --git a/src/langs/zh-CN.json b/src/langs/zh-CN.json index 3511076..15d8ec8 100644 --- a/src/langs/zh-CN.json +++ b/src/langs/zh-CN.json @@ -41,7 +41,7 @@ "speed_text": "速度", "speed_settings": { "title": "速度设置", - "sub_text": "设定速(0.1到10)", + "sub_text": "输入 0.1 到 10 之间的速度值\n速度设置只会影响操作之间的时间间隔,\n不会影响其他与时间相关的设置。", "error_new_value": "速度值必须为0.1到10之间的数值!" }, "repeat_text": "重复", From a49d82c3afe59e00f9ea97edcec117950fccbd74 Mon Sep 17 00:00:00 2001 From: Noah-Jaffe Date: Sat, 12 Sep 2026 11:45:27 -0400 Subject: [PATCH 06/16] auto resize settings windows for the contents it is attempting to display. --- src/windows/options/playback/delay.py | 4 ++++ src/windows/options/playback/repeat.py | 4 ++++ src/windows/options/playback/speed.py | 4 ++++ src/windows/options/playback/time_gui.py | 4 ++++ 4 files changed, 16 insertions(+) diff --git a/src/windows/options/playback/delay.py b/src/windows/options/playback/delay.py index f0e5e2a..c925cc4 100644 --- a/src/windows/options/playback/delay.py +++ b/src/windows/options/playback/delay.py @@ -21,6 +21,10 @@ def __init__(self, parent, main_app): padx=10) Button(buttonArea, text=main_app.text_content["global"]["cancel_button"], command=self.destroy).pack(side=LEFT, padx=10) buttonArea.pack(side=BOTTOM, pady=10) + self.update_idletasks() + popup_width = min(max(300, self.winfo_reqwidth() + 10), 800) + popup_height = min(max(150, self.winfo_reqheight() + 10), 600) + self.geometry(f"{popup_width}x{popup_height}") self.wait_window() main_app.prevent_record = False diff --git a/src/windows/options/playback/repeat.py b/src/windows/options/playback/repeat.py index 523d819..11e624d 100644 --- a/src/windows/options/playback/repeat.py +++ b/src/windows/options/playback/repeat.py @@ -39,6 +39,10 @@ def __init__(self, parent, main_app): command=self.destroy).pack(side=LEFT, padx=5) buttonArea.pack(pady=10) + self.update_idletasks() + popup_width = min(max(300, self.winfo_reqwidth() + 10), 800) + popup_height = min(max(180, self.winfo_reqheight() + 10), 600) + self.geometry(f"{popup_width}x{popup_height}") self.wait_window() main_app.prevent_record = False diff --git a/src/windows/options/playback/speed.py b/src/windows/options/playback/speed.py index 8867908..f28a7ec 100644 --- a/src/windows/options/playback/speed.py +++ b/src/windows/options/playback/speed.py @@ -20,6 +20,10 @@ def __init__(self, parent, main_app): padx=10) Button(buttonArea, text=main_app.text_content["global"]["cancel_button"], command=self.destroy).pack(side=LEFT, padx=10) buttonArea.pack(side=BOTTOM, pady=10) + self.update_idletasks() + popup_width = min(max(300, self.winfo_reqwidth() + 10), 800) + popup_height = min(max(150, self.winfo_reqheight() + 10), 600) + self.geometry(f"{popup_width}x{popup_height}") self.wait_window() main_app.prevent_record = False diff --git a/src/windows/options/playback/time_gui.py b/src/windows/options/playback/time_gui.py index 5ffdb7d..7fbdd66 100644 --- a/src/windows/options/playback/time_gui.py +++ b/src/windows/options/playback/time_gui.py @@ -90,6 +90,10 @@ def __init__(self, parent, main_app, type): ).pack(side=LEFT, padx=10) Button(buttonArea, text=main_app.text_content["global"]["cancel_button"], command=self.destroy).pack(side=LEFT, padx=10) buttonArea.pack(side=BOTTOM, pady=10) + self.update_idletasks() + popup_width = min(max(300, self.winfo_reqwidth() + 10), 800) + popup_height = min(max(height, self.winfo_reqheight() + 10), 600) + self.geometry(f"{popup_width}x{popup_height}") self.wait_window() main_app.prevent_record = False From 04ad50a85fc74e6f9c455b4a95bf81e9b80db173 Mon Sep 17 00:00:00 2001 From: Noah-Jaffe Date: Sat, 12 Sep 2026 11:48:23 -0400 Subject: [PATCH 07/16] allow zero time delay from spinbox button inputs to match the existing logic --- src/windows/options/playback/delay.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/windows/options/playback/delay.py b/src/windows/options/playback/delay.py index c925cc4..b2be425 100644 --- a/src/windows/options/playback/delay.py +++ b/src/windows/options/playback/delay.py @@ -11,14 +11,12 @@ def __init__(self, parent, main_app): self.settings = main_app.settings Label(self, text=main_app.text_content["options_menu"]["playback_menu"]["delay_settings"]["sub_text"], font=('Segoe UI', 10)).pack(side=TOP, pady=10) userSettings = main_app.settings.settings_dict - setNewDelayInput = Spinbox(self, from_=1, to=100000000, width=7, validate="key", - validatecommand=(main_app.validate_cmd_float, "%d", "%P")) + setNewDelayInput = Spinbox(self, from_=0, to=100000000, width=7, validate="key", validatecommand=(main_app.validate_cmd_float, "%d", "%P")) setNewDelayInput.delete(0, "end") setNewDelayInput.insert(0, str(userSettings["Playback"]["Repeat"]["Delay"])) setNewDelayInput.pack(pady=20) buttonArea = Frame(self) - Button(buttonArea, text=main_app.text_content["global"]["confirm_button"], command=lambda: self.setNewDelayNumber(setNewDelayInput.get(), main_app)).pack(side=LEFT, - padx=10) + Button(buttonArea, text=main_app.text_content["global"]["confirm_button"], command=lambda: self.setNewDelayNumber(setNewDelayInput.get(), main_app)).pack(side=LEFT, padx=10) Button(buttonArea, text=main_app.text_content["global"]["cancel_button"], command=self.destroy).pack(side=LEFT, padx=10) buttonArea.pack(side=BOTTOM, pady=10) self.update_idletasks() From 5102519b3bb02d3bc75d6ce0b03b0d8d3c092299 Mon Sep 17 00:00:00 2001 From: Noah-Jaffe Date: Sat, 12 Sep 2026 15:38:26 -0400 Subject: [PATCH 08/16] add language support for the disribution drawer --- src/langs/bg.json | 7 +++++++ src/langs/de.json | 7 +++++++ src/langs/en.json | 7 +++++++ src/langs/eo.json | 7 +++++++ src/langs/es.json | 7 +++++++ src/langs/fr.json | 7 +++++++ src/langs/it.json | 7 +++++++ src/langs/ko.json | 7 +++++++ src/langs/nl.json | 7 +++++++ src/langs/pt-BR.json | 7 +++++++ src/langs/ru-RU.json | 7 +++++++ src/langs/tr.json | 7 +++++++ src/langs/zh-CN.json | 7 +++++++ 13 files changed, 91 insertions(+) diff --git a/src/langs/bg.json b/src/langs/bg.json index b7f76ce..c570d09 100644 --- a/src/langs/bg.json +++ b/src/langs/bg.json @@ -83,6 +83,13 @@ "lower_text": "Долна граница:", "upper_text": "Горна граница:", "error_new_value": "И двете граници на случайното забавяне трябва да бъдат валидни числа." + }, + "distribution_drawer_settings": { + "title": "Персонализиране на разпределението", + "sub_text": "Начертайте колко често трябва да се среща всяко забавяне.", + "more_text": "По-често", + "less_text": "По-рядко", + "clear_text": "Изчистване" } }, "recordings_menu": { diff --git a/src/langs/de.json b/src/langs/de.json index 1c7c8a9..2158c02 100644 --- a/src/langs/de.json +++ b/src/langs/de.json @@ -83,6 +83,13 @@ "lower_text": "Untergrenze:", "upper_text": "Obergrenze:", "error_new_value": "Beide Grenzen der zufälligen Verzögerung müssen gültige Zahlen sein." + }, + "distribution_drawer_settings": { + "title": "Verteilung anpassen", + "sub_text": "Zeichnen Sie, wie häufig jede Verzögerung vorkommen soll.", + "more_text": "Häufiger", + "less_text": "Seltener", + "clear_text": "Löschen" } }, "recordings_menu": { diff --git a/src/langs/en.json b/src/langs/en.json index 6b88ec3..d9585f7 100644 --- a/src/langs/en.json +++ b/src/langs/en.json @@ -83,6 +83,13 @@ "lower_text": "Lower:", "upper_text": "Upper:", "error_new_value": "Both randomized delay bounds must be valid numbers." + }, + "distribution_drawer_settings": { + "title": "Customize distribution", + "sub_text": "Draw how common each delay should be.", + "more_text": "More common", + "less_text": "Less common", + "clear_text": "Clear" } }, "recordings_menu": { diff --git a/src/langs/eo.json b/src/langs/eo.json index 2d128e2..857df85 100644 --- a/src/langs/eo.json +++ b/src/langs/eo.json @@ -83,6 +83,13 @@ "lower_text": "Malsupra:", "upper_text": "Supra:", "error_new_value": "Ambaŭ limoj de la hazarda prokrasto devas esti validaj nombroj." + }, + "distribution_drawer_settings": { + "title": "Agordi distribuon", + "sub_text": "Desegnu, kiom ofta estu ĉiu prokrasto.", + "more_text": "Pli ofta", + "less_text": "Malpli ofta", + "clear_text": "Forigi" } }, "recordings_menu": { diff --git a/src/langs/es.json b/src/langs/es.json index 92904dd..dd1c32a 100644 --- a/src/langs/es.json +++ b/src/langs/es.json @@ -83,6 +83,13 @@ "lower_text": "Inferior:", "upper_text": "Superior:", "error_new_value": "Ambos límites del retraso aleatorio deben ser números válidos." + }, + "distribution_drawer_settings": { + "title": "Personalizar la distribución", + "sub_text": "Dibuja con qué frecuencia debería aparecer cada retraso.", + "more_text": "Más frecuente", + "less_text": "Menos frecuente", + "clear_text": "Borrar" } }, "recordings_menu": { diff --git a/src/langs/fr.json b/src/langs/fr.json index f36d871..fb0495e 100644 --- a/src/langs/fr.json +++ b/src/langs/fr.json @@ -83,6 +83,13 @@ "lower_text": "Inférieure :", "upper_text": "Supérieure :", "error_new_value": "Les deux limites du délai aléatoire doivent être des nombres valides." + }, + "distribution_drawer_settings": { + "title": "Personnaliser la distribution", + "sub_text": "Dessinez la fréquence à laquelle chaque délai doit apparaître.", + "more_text": "Plus fréquent", + "less_text": "Moins fréquent", + "clear_text": "Effacer" } }, "recordings_menu": { diff --git a/src/langs/it.json b/src/langs/it.json index 7138444..de51182 100644 --- a/src/langs/it.json +++ b/src/langs/it.json @@ -83,6 +83,13 @@ "lower_text": "Inferiore:", "upper_text": "Superiore:", "error_new_value": "Entrambi i limiti del ritardo casuale devono essere numeri validi." + }, + "distribution_drawer_settings": { + "title": "Personalizza la distribuzione", + "sub_text": "Disegna la frequenza con cui dovrebbe verificarsi ciascun ritardo.", + "more_text": "Più frequente", + "less_text": "Meno frequente", + "clear_text": "Cancella" } }, "recordings_menu": { diff --git a/src/langs/ko.json b/src/langs/ko.json index 2ab96b3..e9f77a1 100644 --- a/src/langs/ko.json +++ b/src/langs/ko.json @@ -83,6 +83,13 @@ "lower_text": "하한:", "upper_text": "상한:", "error_new_value": "무작위 지연의 두 범위 모두 유효한 숫자여야 합니다." + }, + "distribution_drawer_settings": { + "title": "분포 사용자 지정", + "sub_text": "각 지연이 얼마나 자주 발생할지 그려보세요.", + "more_text": "더 자주", + "less_text": "덜 자주", + "clear_text": "지우기" } }, "recordings_menu": { diff --git a/src/langs/nl.json b/src/langs/nl.json index 1f4e0d9..3b1e8e1 100644 --- a/src/langs/nl.json +++ b/src/langs/nl.json @@ -83,6 +83,13 @@ "lower_text": "Ondergrens:", "upper_text": "Bovengrens:", "error_new_value": "Beide grenzen van de willekeurige vertraging moeten geldige getallen zijn." + }, + "distribution_drawer_settings": { + "title": "Verdeling aanpassen", + "sub_text": "Teken hoe vaak elke vertraging moet voorkomen.", + "more_text": "Vaker", + "less_text": "Minder vaak", + "clear_text": "Wissen" } }, "recordings_menu": { diff --git a/src/langs/pt-BR.json b/src/langs/pt-BR.json index 2d2ebe7..c99fd65 100644 --- a/src/langs/pt-BR.json +++ b/src/langs/pt-BR.json @@ -83,6 +83,13 @@ "lower_text": "Inferior:", "upper_text": "Superior:", "error_new_value": "Ambos os limites do atraso aleatório devem ser números válidos." + }, + "distribution_drawer_settings": { + "title": "Personalizar distribuição", + "sub_text": "Desenhe com que frequência cada atraso deve ocorrer.", + "more_text": "Mais frequente", + "less_text": "Menos frequente", + "clear_text": "Limpar" } }, "recordings_menu": { diff --git a/src/langs/ru-RU.json b/src/langs/ru-RU.json index 13bb6b7..ff3b045 100644 --- a/src/langs/ru-RU.json +++ b/src/langs/ru-RU.json @@ -83,6 +83,13 @@ "lower_text": "Нижняя граница:", "upper_text": "Верхняя граница:", "error_new_value": "Обе границы случайной задержки должны быть допустимыми числами." + }, + "distribution_drawer_settings": { + "title": "Настроить распределение", + "sub_text": "Нарисуйте, насколько часто должна встречаться каждая задержка.", + "more_text": "Чаще", + "less_text": "Реже", + "clear_text": "Очистить" } }, "recordings_menu": { diff --git a/src/langs/tr.json b/src/langs/tr.json index 941b2b5..f7f133b 100644 --- a/src/langs/tr.json +++ b/src/langs/tr.json @@ -84,6 +84,13 @@ "lower_text": "Alt:", "upper_text": "Üst:", "error_new_value": "Rastgele gecikmenin her iki sınırı da geçerli sayılar olmalıdır." + }, + "distribution_drawer_settings": { + "title": "Dağılımı özelleştir", + "sub_text": "Her gecikmenin ne kadar sık olması gerektiğini çizin.", + "more_text": "Daha sık", + "less_text": "Daha az sık", + "clear_text": "Temizle" } }, "recordings_menu": { diff --git a/src/langs/zh-CN.json b/src/langs/zh-CN.json index 15d8ec8..f53166f 100644 --- a/src/langs/zh-CN.json +++ b/src/langs/zh-CN.json @@ -83,6 +83,13 @@ "lower_text": "下限:", "upper_text": "上限:", "error_new_value": "随机延迟的两个范围都必须是有效数字。" + }, + "distribution_drawer_settings": { + "title": "自定义分布", + "sub_text": "绘制每种延迟出现的频率。", + "more_text": "更常见", + "less_text": "较少见", + "clear_text": "清除" } }, "recordings_menu": { From 38527218deeadcf7ab47592115d02f95430a1fe4 Mon Sep 17 00:00:00 2001 From: Noah-Jaffe Date: Sat, 12 Sep 2026 15:39:12 -0400 Subject: [PATCH 09/16] implementation for randomized delay between actions --- src/macro/macro.py | 45 ++- .../options/playback/randomized_delay.py | 342 ++++++++++++++++-- 2 files changed, 356 insertions(+), 31 deletions(-) diff --git a/src/macro/macro.py b/src/macro/macro.py index f50661e..f54a95f 100644 --- a/src/macro/macro.py +++ b/src/macro/macro.py @@ -1,4 +1,5 @@ from datetime import datetime +import random from os import getlogin, system from sys import platform from threading import Thread @@ -15,6 +16,15 @@ from utils.show_toast import show_notification_minim from utils.warning_pop_up_save import confirm_save +# at arbitrary times, we re-seed randomness so that it is harder to reverse-engineer the random usage +def update_random_seed(seed=None, version=2): + """Update the random package seed. + + Args: + seed (int|float|str|bytes|bytearray, optional): The seed to be used. None will default to using system time. Defaults to None (aka, system time). + version (1|2, optional): the version for the generation of seeding algorithm, the higher means the more advanced. Defaults to the maximum valid value. + """ + random.seed(a=seed, version=version) class Macro: """Init a new Macro""" @@ -223,9 +233,38 @@ def __play_events(self): self.macro_events["events"][events]["timestamp"] * (1 / userSettings["Playback"]["Speed"]) ) - if timeSleep < 0: - timeSleep = abs(timeSleep) - sleep(timeSleep) + + # base sleep time + base_ms = self.macro_events["events"][events]["timestamp"] + # the speed adjustments only affect the base interval between actions, it should not affect other time modifiers + adjusted_speed = 1/(userSettings["Playback"]["Speed"] or 1) + # randomized delay between actions, default to zero for no change + randomized_delay_ms = None + randomized_delay_opts = userSettings["Playback"].get("Randomized_Delay", {}) + randomized_delay_weights = randomized_delay_opts.get("Distribution") + if randomized_delay_opts.get("Enabled", False): + lower_bound = float(randomized_delay_opts["Lower"]) + upper_bound = float(randomized_delay_opts["Upper"]) + + if lower_bound > upper_bound: + lower_bound, upper_bound = upper_bound, lower_bound + try: + randomized_delay_ms = random.choices(range(lower_bound, upper_bound, (upper_bound-lower_bound)/(len(randomized_delay_weights) or 1)), weights=randomized_delay_weights, k=1) + except Exception as e: + print(e) + pass + if randomized_delay_ms is None: + # default to linear/equal distribution if any issues occur + randomized_delay_ms = ((upper_bound - lower_bound) * random.random()) + lower_bound + + update_random_seed() + + # sleep for the adjusted speed delay, and add the randomized delay time + sleep_time = max(0, (base_ms * adjusted_speed) + ((randomized_delay_ms or 0)/1000)) + + sleep(sleep_time) + + # continue to execute action event_type = self.macro_events["events"][events]["type"] if event_type == "cursorMove": # Cursor Move diff --git a/src/windows/options/playback/randomized_delay.py b/src/windows/options/playback/randomized_delay.py index 72fd0cf..d265f0f 100644 --- a/src/windows/options/playback/randomized_delay.py +++ b/src/windows/options/playback/randomized_delay.py @@ -1,4 +1,4 @@ -from tkinter import BOTTOM, LEFT, TOP, Spinbox, messagebox +from tkinter import BOTTOM, LEFT, RIGHT, TOP, Canvas, Spinbox, messagebox from tkinter.ttk import Button, Frame, Label from sys import maxsize as INT_BOUND from windows.popup import Popup @@ -8,47 +8,333 @@ class RandomizedDelay(Popup): def __init__(self, parent, main_app): super().__init__(main_app.text_content["options_menu"]["playback_menu"]["randomized_delay_settings"]["title"], 350, 180, parent) main_app.prevent_record = True + self.main_app = main_app self.settings = main_app.settings - Label(self, text=main_app.text_content["options_menu"]["playback_menu"]["randomized_delay_settings"]["sub_text"], font=("Segoe UI", 10)).pack(side=TOP, pady=10) - userSettings = main_app.settings.settings_dict - randomized_delay = userSettings["Playback"].get("Randomized_Delay",{"Enabled": False, "Lower": 0, "Upper": 0}) - inputArea = Frame(self) - Label(inputArea, text=main_app.text_content["options_menu"]["playback_menu"]["randomized_delay_settings"]["lower_text"]).pack(side=LEFT, padx=5) - lowerInput = Spinbox(inputArea, from_=-1*INT_BOUND, to=INT_BOUND, width=9, validate="key", validatecommand=(main_app.validate_cmd_float, "%d", "%P")) - lowerInput.delete(0, "end") - lowerInput.insert(0, str(randomized_delay.get("Lower", 0))) - lowerInput.pack(side=LEFT, padx=5) - Label(inputArea, text=main_app.text_content["options_menu"]["playback_menu"]["randomized_delay_settings"]["upper_text"]).pack(side=LEFT, padx=5) - upperInput = Spinbox(inputArea, from_=-1*INT_BOUND, to=INT_BOUND, width=9, validate="key", validatecommand=(main_app.validate_cmd_float, "%d", "%P")) - upperInput.delete(0, "end") - upperInput.insert(0, str(randomized_delay.get("Upper", 0))) - upperInput.pack(side=LEFT, padx=5) - inputArea.pack(pady=10) - buttonArea = Frame(self) - Button(buttonArea, text=main_app.text_content["global"]["confirm_button"], command=lambda: self.setNewValues(lowerInput.get(), upperInput.get(), main_app)).pack(side=LEFT, padx=10) - Button(buttonArea, text=main_app.text_content["global"]["cancel_button"], command=self.destroy).pack(side=LEFT, padx=10) - buttonArea.pack(side=BOTTOM, pady=10) + text = main_app.text_content["options_menu"]["playback_menu"]["randomized_delay_settings"] + Label(self, text=text["sub_text"], font=("Segoe UI", 10)).pack(side=TOP, pady=10) + user_settings = main_app.settings.settings_dict + randomized_delay = user_settings["Playback"].get("Randomized_Delay", + { + "Enabled": False, + "Lower": 0, + "Upper": 0, + "Distribution": None + } + ) + # Keep the currently saved distribution while this popup is open. + self.distribution = randomized_delay.get("Distribution") + input_area = Frame(self) + Label(input_area, text=text["lower_text"]).pack(side=LEFT, padx=5) + self.lowerInput = Spinbox(input_area, from_=-1 * INT_BOUND, to=INT_BOUND, width=9, validate="key", validatecommand=(main_app.validate_cmd_float, "%d", "%P")) + self.lowerInput.delete(0, "end") + self.lowerInput.insert(0, str(randomized_delay.get("Lower", 0))) + self.lowerInput.pack(side=LEFT, padx=5) + Label(input_area, text=text["upper_text"]).pack(side=LEFT, padx=5) + self.upperInput = Spinbox(input_area, from_=-1 * INT_BOUND, to=INT_BOUND, width=9, validate="key", validatecommand=(main_app.validate_cmd_float, "%d", "%P")) + self.upperInput.delete(0, "end") + self.upperInput.insert(0, str(randomized_delay.get("Upper", 0))) + self.upperInput.pack(side=LEFT, padx=5) + input_area.pack(pady=10) + distribution_area = Frame(self) + Button(distribution_area, text=text.get("distribution_button", "Customize distribution"), command=self.open_distribution_editor).pack(side=LEFT, padx=5) + distribution_area.pack(pady=5) + button_area = Frame(self) + Button(button_area, text=main_app.text_content["global"]["confirm_button"], command=lambda: self.setNewValues(self.lowerInput.get(), self.upperInput.get(), main_app)).pack(side=LEFT, padx=10) + Button(button_area, text=main_app.text_content["global"]["cancel_button"], command=self.destroy).pack(side=LEFT, padx=10) + button_area.pack(side=BOTTOM, pady=10) self.update_idletasks() - popup_width = min(max(350, self.winfo_reqwidth() + 10), 800) popup_height = min(max(180, self.winfo_reqheight() + 10), 600) self.geometry(f"{popup_width}x{popup_height}") self.wait_window() main_app.prevent_record = False + def open_distribution_editor(self): + """Open the distribution drawer as a child of this popup.""" + + try: + lower_bound = float(self.lowerInput.get()) + upper_bound = float(self.upperInput.get()) + except ValueError: + messagebox.showerror(self.main_app.text_content["global"]["error"], self.main_app.text_content["options_menu"]["playback_menu"]["randomized_delay_settings"]["error_new_value"]) + return + + if lower_bound > upper_bound: + lower_bound, upper_bound = upper_bound, lower_bound + + DistributionDrawer(self, self.main_app, lower_bound, upper_bound, self.distribution) + def setNewValues(self, lower_bound, upper_bound, main_app): - """Function to set the new Randomized Delay numbers""" + """Set the new Randomized Delay values.""" try: lower_bound = float(lower_bound) upper_bound = float(upper_bound) except ValueError: - messagebox.showerror( - main_app.text_content["global"]["error"], - main_app.text_content["options_menu"]["playback_menu"]["randomized_delay_settings"]["error_new_value"], - ) + messagebox.showerror(main_app.text_content["global"]["error"], main_app.text_content["options_menu"]["playback_menu"]["randomized_delay_settings"]["error_new_value"]) return if lower_bound > upper_bound: lower_bound, upper_bound = upper_bound, lower_bound enabled = lower_bound != 0 or upper_bound != 0 - self.settings.change_settings("Playback", "Randomized_Delay", None, {"Enabled": enabled, "Lower": lower_bound, "Upper": upper_bound}) - self.destroy() \ No newline at end of file + self.settings.change_settings("Playback", "Randomized_Delay", None, { + "Enabled": enabled, + "Lower": lower_bound, + "Upper": upper_bound, + "Distribution": self.distribution + } + ) + self.destroy() + + +class DistributionDrawer(Popup): + """ + Simple visual distribution editor. + + The user draws a curve: + higher = delay happens more often + lower = delay happens less often + + Distribution is stored as normalized points: + + [ + [0.0, 0.0], + [0.1, 0.2], + [0.2, 0.5], + ... + [1.0, 0.0] + ] + + X is the position between Lower and Upper. + Y is how common that value should be. + """ + + CANVAS_WIDTH = 600 + CANVAS_HEIGHT = 300 + SAMPLE_COUNT = 128 + + def __init__(self, parent, main_app, lower_bound, upper_bound, distribution=None): + text = main_app.text_content["options_menu"]["playback_menu"]["randomized_delay_settings"] + super().__init__(main_app.text_content["options_menu"]["playback_menu"]["distribution_drawer_settings"]["title"], 680, 470, parent) + self.owner = parent + self.main_app = main_app + self.lower_bound = lower_bound + self.upper_bound = upper_bound + self.points = [] + Label(self, text=main_app.text_content["options_menu"]["playback_menu"]["distribution_drawer_settings"]["sub_text"], font=("Segoe UI", 10)).pack(side=TOP, pady=(10, 8)) + + graph_frame = Frame(self) + graph_frame.pack(padx=15, pady=5) + + Label(graph_frame, text=main_app.text_content["options_menu"]["playback_menu"]["distribution_drawer_settings"]["more_text"]).pack(side=TOP, pady=(0, 2)) + + canvas_frame = Frame(graph_frame, relief="solid", borderwidth=1) + canvas_frame.pack() + + self.canvas = Canvas(canvas_frame, width=self.CANVAS_WIDTH, height=self.CANVAS_HEIGHT, highlightthickness=0) + self.canvas.pack() + + self._draw_grid() + + info_area = Frame(graph_frame) + Label(info_area, text=self._format_number(lower_bound)).pack(side=LEFT, padx=(0, 250)) + Label(info_area, text=self._format_number(upper_bound)).pack(side=RIGHT, padx=(250, 0)) + info_area.pack(fill="x", pady=(4, 0)) + + Label(graph_frame, text=main_app.text_content["options_menu"]["playback_menu"]["distribution_drawer_settings"]["less_text"]).pack(side=TOP, pady=(2, 0)) + + button_area = Frame(self) + Button(button_area, text=main_app.text_content["options_menu"]["playback_menu"]["distribution_drawer_settings"]["clear_text"], command=self.clear).pack(side=LEFT, padx=10) + Button(button_area, text=main_app.text_content["global"]["confirm_button"], command=self.apply).pack(side=LEFT, padx=10) + Button(button_area, text=main_app.text_content["global"]["cancel_button"], command=self.destroy).pack(side=LEFT, padx=10) + button_area.pack(side=BOTTOM, pady=10) + + self.canvas.bind("", self.start_drawing) + self.canvas.bind("", self.draw) + self.canvas.bind("", self.finish_drawing) + # Draw the existing distribution, if one exists. + self.load_distribution(distribution) + self.update_idletasks() + popup_width = min(max(680, self.winfo_reqwidth() + 10), 900) + popup_height = min(max(470, self.winfo_reqheight() + 10), 700) + self.geometry(f"{popup_width}x{popup_height}") + self.wait_window() + + # def __init__(self, parent, main_app, lower_bound, upper_bound, distribution=None): + # text = main_app.text_content["options_menu"]["playback_menu"]["randomized_delay_settings"] + # super().__init__(main_app.text_content["options_menu"]["playback_menu"]["randomized_delay_settings"]["title"], 640, 430, parent) + # self.owner = parent + # self.main_app = main_app + # self.lower_bound = lower_bound + # self.upper_bound = upper_bound + # self.points = [] + # Label(self, text=main_app.text_content["options_menu"]["playback_menu"]["distribution_drawer_settings"]["title"], font=("Segoe UI", 10)).pack(side=TOP, pady=(10, 4)) + # canvas_frame = Frame(self) + # self.canvas = Canvas(canvas_frame, width=self.CANVAS_WIDTH, height=self.CANVAS_HEIGHT, highlightthickness=1) + # self.canvas.pack() + # canvas_frame.pack(padx=10, pady=5) + # info_area = Frame(self) + # Label(info_area, text=main_app.text_content["options_menu"]["playback_menu"]["distribution_drawer_settings"]["more_text"]).pack(side=TOP) + # Label(info_area, text=f"◄\t{self._format_number(lower_bound)} | {self._format_number(upper_bound)} ►").pack(side=TOP) + # Label(info_area, text=main_app.text_content["options_menu"]["playback_menu"]["distribution_drawer_settings"]["less_text"]).pack(side=TOP) + # info_area.pack(pady=2) + # button_area = Frame(self) + # Button(button_area, text=main_app.text_content["options_menu"]["playback_menu"]["distribution_drawer_settings"]["clear_text"], command=self.clear).pack(side=LEFT, padx=10) + # Button(button_area, text=main_app.text_content["global"]["confirm_button"], command=self.apply).pack(side=LEFT, padx=10) + # Button(button_area, text=main_app.text_content["global"]["cancel_button"], command=self.destroy).pack(side=LEFT, padx=10) + # button_area.pack(side=BOTTOM, pady=10) + # self.canvas.bind("", self.start_drawing) + # self.canvas.bind("", self.draw) + # self.canvas.bind("", self.finish_drawing) + # # Draw the existing distribution, if one exists. + # self.load_distribution(distribution) + # self.update_idletasks() + # popup_width = min(max(640, self.winfo_reqwidth() + 10), 900) + # popup_height = min(max(430, self.winfo_reqheight() + 10), 700) + # self.geometry(f"{popup_width}x{popup_height}") + # self.wait_window() + + def start_drawing(self, event): + self.points = [(self._canvas_to_normalized_x(event.x), self._canvas_to_normalized_y(event.y))] + self.redraw() + + def draw(self, event): + x = self._canvas_to_normalized_x(event.x) + y = self._canvas_to_normalized_y(event.y) + self.points.append((x, y)) + self.redraw() + + def finish_drawing(self, event): + x = self._canvas_to_normalized_x(event.x) + y = self._canvas_to_normalized_y(event.y) + + self.points.append((x, y)) + self.points = self._normalize_points(self.points) + self.redraw() + + def _canvas_to_normalized_x(self, x): + x = max(0, min(self.CANVAS_WIDTH, x)) + return x / self.CANVAS_WIDTH + + def _canvas_to_normalized_y(self, y): + y = max(0, min(self.CANVAS_HEIGHT, y)) + return 1.0 - (y / self.CANVAS_HEIGHT) + + def _normalized_to_canvas(self, x, y): + return (x * self.CANVAS_WIDTH, (1.0 - y) * self.CANVAS_HEIGHT) + + def _draw_grid(self): + for x in range(0, self.CANVAS_WIDTH + 1, self.CANVAS_WIDTH // 10): + self.canvas.create_line(x, 0, x, self.CANVAS_HEIGHT, fill="#d9d9d9", tags="grid") + for y in range(0, self.CANVAS_HEIGHT + 1, self.CANVAS_HEIGHT // 10): + self.canvas.create_line(0, y, self.CANVAS_WIDTH, y, fill="#d9d9d9", tags="grid") + + def _normalize_points(self, points): + """ + Convert arbitrary mouse input into one point per X position. + + This means the saved distribution isn't tied to screen pixels. + """ + if not points: + return [] + cleaned = [] + for x, y in points: + x = max(0.0, min(1.0, float(x))) + y = max(0.0, min(1.0, float(y))) + cleaned.append((x, y)) + cleaned.sort(key=lambda point: point[0]) + # Merge points that have effectively the same X. + merged = [] + for x, y in cleaned: + if merged and abs(merged[-1][0] - x) < 0.002: + old_x, old_y = merged[-1] + merged[-1] = ((old_x + x) / 2, (old_y + y) / 2) + else: + merged.append((x, y)) + return merged + + def redraw(self): + self.canvas.delete("distribution") + if len(self.points) < 1: + return + coords = [] + for x, y in self.points: + canvas_x, canvas_y = self._normalized_to_canvas(x, y) + coords.extend((canvas_x, canvas_y)) + if len(coords) >= 4: + self.canvas.create_line(*coords, width=3, smooth=True, tags="distribution") + else: + x, y = self._normalized_to_canvas(self.points[0][0], self.points[0][1]) + self.canvas.create_oval(x - 2, y - 2, x + 2, y + 2, tags="distribution") + + def load_distribution(self, distribution): + if not distribution: + return + try: + points = [] + for point in distribution: + if len(point) != 2: + continue + x = float(point[0]) + y = float(point[1]) + x = max(0.0, min(1.0, x)) + y = max(0.0, min(1.0, y)) + points.append((x, y)) + self.points = self._normalize_points(points) + self.redraw() + except (TypeError, ValueError): + self.points = [] + + def clear(self): + self.points = [] + self.canvas.delete("distribution") + + def apply(self): + if not self.points: + self.owner.distribution = None + self.destroy() + return + distribution = self._resample_distribution() + if not distribution: + self.owner.distribution = None + else: + self.owner.distribution = distribution + self.destroy() + + def _resample_distribution(self): + """ + Convert the freehand line into a fixed number of evenly spaced + points. This makes the saved data compact and predictable. + """ + points = self._normalize_points(self.points) + if len(points) < 2: + return None + result = [] + for index in range(self.SAMPLE_COUNT): + x = index / (self.SAMPLE_COUNT - 1) + y = self._interpolate(points, x) + result.append([round(x, 6), round(max(0.0, min(1.0, y)), 6)]) + # If the entire drawing is at zero, there is no distribution. + if max(point[1] for point in result) <= 0: + return None + return result + + @staticmethod + def _interpolate(points, x): + if x <= points[0][0]: + return points[0][1] + if x >= points[-1][0]: + return points[-1][1] + for index in range(1, len(points)): + x1, y1 = points[index - 1] + x2, y2 = points[index] + if x <= x2: + if x2 == x1: + return y2 + amount = (x - x1) / (x2 - x1) + return y1 + ((y2 - y1) * amount) + return points[-1][1] + + @staticmethod + def _format_number(value): + if float(value).is_integer(): + return str(int(value)) + return str(value) \ No newline at end of file From 00abacd1c9086e1022f1725c1a846fed3f94f27d Mon Sep 17 00:00:00 2001 From: Noah-Jaffe Date: Sun, 13 Sep 2026 11:44:40 -0400 Subject: [PATCH 10/16] move distributiondrawer to its own file --- .../options/playback/randomized_delay.py | 259 +----------------- src/windows/others/__init__.py | 1 + src/windows/others/distribution.py | 257 +++++++++++++++++ 3 files changed, 260 insertions(+), 257 deletions(-) create mode 100644 src/windows/others/distribution.py diff --git a/src/windows/options/playback/randomized_delay.py b/src/windows/options/playback/randomized_delay.py index d265f0f..0256868 100644 --- a/src/windows/options/playback/randomized_delay.py +++ b/src/windows/options/playback/randomized_delay.py @@ -1,9 +1,9 @@ -from tkinter import BOTTOM, LEFT, RIGHT, TOP, Canvas, Spinbox, messagebox +from tkinter import BOTTOM, LEFT, TOP, Spinbox, messagebox from tkinter.ttk import Button, Frame, Label from sys import maxsize as INT_BOUND +from src.windows.others.distribution import DistributionDrawer from windows.popup import Popup - class RandomizedDelay(Popup): def __init__(self, parent, main_app): super().__init__(main_app.text_content["options_menu"]["playback_menu"]["randomized_delay_settings"]["title"], 350, 180, parent) @@ -83,258 +83,3 @@ def setNewValues(self, lower_bound, upper_bound, main_app): } ) self.destroy() - - -class DistributionDrawer(Popup): - """ - Simple visual distribution editor. - - The user draws a curve: - higher = delay happens more often - lower = delay happens less often - - Distribution is stored as normalized points: - - [ - [0.0, 0.0], - [0.1, 0.2], - [0.2, 0.5], - ... - [1.0, 0.0] - ] - - X is the position between Lower and Upper. - Y is how common that value should be. - """ - - CANVAS_WIDTH = 600 - CANVAS_HEIGHT = 300 - SAMPLE_COUNT = 128 - - def __init__(self, parent, main_app, lower_bound, upper_bound, distribution=None): - text = main_app.text_content["options_menu"]["playback_menu"]["randomized_delay_settings"] - super().__init__(main_app.text_content["options_menu"]["playback_menu"]["distribution_drawer_settings"]["title"], 680, 470, parent) - self.owner = parent - self.main_app = main_app - self.lower_bound = lower_bound - self.upper_bound = upper_bound - self.points = [] - Label(self, text=main_app.text_content["options_menu"]["playback_menu"]["distribution_drawer_settings"]["sub_text"], font=("Segoe UI", 10)).pack(side=TOP, pady=(10, 8)) - - graph_frame = Frame(self) - graph_frame.pack(padx=15, pady=5) - - Label(graph_frame, text=main_app.text_content["options_menu"]["playback_menu"]["distribution_drawer_settings"]["more_text"]).pack(side=TOP, pady=(0, 2)) - - canvas_frame = Frame(graph_frame, relief="solid", borderwidth=1) - canvas_frame.pack() - - self.canvas = Canvas(canvas_frame, width=self.CANVAS_WIDTH, height=self.CANVAS_HEIGHT, highlightthickness=0) - self.canvas.pack() - - self._draw_grid() - - info_area = Frame(graph_frame) - Label(info_area, text=self._format_number(lower_bound)).pack(side=LEFT, padx=(0, 250)) - Label(info_area, text=self._format_number(upper_bound)).pack(side=RIGHT, padx=(250, 0)) - info_area.pack(fill="x", pady=(4, 0)) - - Label(graph_frame, text=main_app.text_content["options_menu"]["playback_menu"]["distribution_drawer_settings"]["less_text"]).pack(side=TOP, pady=(2, 0)) - - button_area = Frame(self) - Button(button_area, text=main_app.text_content["options_menu"]["playback_menu"]["distribution_drawer_settings"]["clear_text"], command=self.clear).pack(side=LEFT, padx=10) - Button(button_area, text=main_app.text_content["global"]["confirm_button"], command=self.apply).pack(side=LEFT, padx=10) - Button(button_area, text=main_app.text_content["global"]["cancel_button"], command=self.destroy).pack(side=LEFT, padx=10) - button_area.pack(side=BOTTOM, pady=10) - - self.canvas.bind("", self.start_drawing) - self.canvas.bind("", self.draw) - self.canvas.bind("", self.finish_drawing) - # Draw the existing distribution, if one exists. - self.load_distribution(distribution) - self.update_idletasks() - popup_width = min(max(680, self.winfo_reqwidth() + 10), 900) - popup_height = min(max(470, self.winfo_reqheight() + 10), 700) - self.geometry(f"{popup_width}x{popup_height}") - self.wait_window() - - # def __init__(self, parent, main_app, lower_bound, upper_bound, distribution=None): - # text = main_app.text_content["options_menu"]["playback_menu"]["randomized_delay_settings"] - # super().__init__(main_app.text_content["options_menu"]["playback_menu"]["randomized_delay_settings"]["title"], 640, 430, parent) - # self.owner = parent - # self.main_app = main_app - # self.lower_bound = lower_bound - # self.upper_bound = upper_bound - # self.points = [] - # Label(self, text=main_app.text_content["options_menu"]["playback_menu"]["distribution_drawer_settings"]["title"], font=("Segoe UI", 10)).pack(side=TOP, pady=(10, 4)) - # canvas_frame = Frame(self) - # self.canvas = Canvas(canvas_frame, width=self.CANVAS_WIDTH, height=self.CANVAS_HEIGHT, highlightthickness=1) - # self.canvas.pack() - # canvas_frame.pack(padx=10, pady=5) - # info_area = Frame(self) - # Label(info_area, text=main_app.text_content["options_menu"]["playback_menu"]["distribution_drawer_settings"]["more_text"]).pack(side=TOP) - # Label(info_area, text=f"◄\t{self._format_number(lower_bound)} | {self._format_number(upper_bound)} ►").pack(side=TOP) - # Label(info_area, text=main_app.text_content["options_menu"]["playback_menu"]["distribution_drawer_settings"]["less_text"]).pack(side=TOP) - # info_area.pack(pady=2) - # button_area = Frame(self) - # Button(button_area, text=main_app.text_content["options_menu"]["playback_menu"]["distribution_drawer_settings"]["clear_text"], command=self.clear).pack(side=LEFT, padx=10) - # Button(button_area, text=main_app.text_content["global"]["confirm_button"], command=self.apply).pack(side=LEFT, padx=10) - # Button(button_area, text=main_app.text_content["global"]["cancel_button"], command=self.destroy).pack(side=LEFT, padx=10) - # button_area.pack(side=BOTTOM, pady=10) - # self.canvas.bind("", self.start_drawing) - # self.canvas.bind("", self.draw) - # self.canvas.bind("", self.finish_drawing) - # # Draw the existing distribution, if one exists. - # self.load_distribution(distribution) - # self.update_idletasks() - # popup_width = min(max(640, self.winfo_reqwidth() + 10), 900) - # popup_height = min(max(430, self.winfo_reqheight() + 10), 700) - # self.geometry(f"{popup_width}x{popup_height}") - # self.wait_window() - - def start_drawing(self, event): - self.points = [(self._canvas_to_normalized_x(event.x), self._canvas_to_normalized_y(event.y))] - self.redraw() - - def draw(self, event): - x = self._canvas_to_normalized_x(event.x) - y = self._canvas_to_normalized_y(event.y) - self.points.append((x, y)) - self.redraw() - - def finish_drawing(self, event): - x = self._canvas_to_normalized_x(event.x) - y = self._canvas_to_normalized_y(event.y) - - self.points.append((x, y)) - self.points = self._normalize_points(self.points) - self.redraw() - - def _canvas_to_normalized_x(self, x): - x = max(0, min(self.CANVAS_WIDTH, x)) - return x / self.CANVAS_WIDTH - - def _canvas_to_normalized_y(self, y): - y = max(0, min(self.CANVAS_HEIGHT, y)) - return 1.0 - (y / self.CANVAS_HEIGHT) - - def _normalized_to_canvas(self, x, y): - return (x * self.CANVAS_WIDTH, (1.0 - y) * self.CANVAS_HEIGHT) - - def _draw_grid(self): - for x in range(0, self.CANVAS_WIDTH + 1, self.CANVAS_WIDTH // 10): - self.canvas.create_line(x, 0, x, self.CANVAS_HEIGHT, fill="#d9d9d9", tags="grid") - for y in range(0, self.CANVAS_HEIGHT + 1, self.CANVAS_HEIGHT // 10): - self.canvas.create_line(0, y, self.CANVAS_WIDTH, y, fill="#d9d9d9", tags="grid") - - def _normalize_points(self, points): - """ - Convert arbitrary mouse input into one point per X position. - - This means the saved distribution isn't tied to screen pixels. - """ - if not points: - return [] - cleaned = [] - for x, y in points: - x = max(0.0, min(1.0, float(x))) - y = max(0.0, min(1.0, float(y))) - cleaned.append((x, y)) - cleaned.sort(key=lambda point: point[0]) - # Merge points that have effectively the same X. - merged = [] - for x, y in cleaned: - if merged and abs(merged[-1][0] - x) < 0.002: - old_x, old_y = merged[-1] - merged[-1] = ((old_x + x) / 2, (old_y + y) / 2) - else: - merged.append((x, y)) - return merged - - def redraw(self): - self.canvas.delete("distribution") - if len(self.points) < 1: - return - coords = [] - for x, y in self.points: - canvas_x, canvas_y = self._normalized_to_canvas(x, y) - coords.extend((canvas_x, canvas_y)) - if len(coords) >= 4: - self.canvas.create_line(*coords, width=3, smooth=True, tags="distribution") - else: - x, y = self._normalized_to_canvas(self.points[0][0], self.points[0][1]) - self.canvas.create_oval(x - 2, y - 2, x + 2, y + 2, tags="distribution") - - def load_distribution(self, distribution): - if not distribution: - return - try: - points = [] - for point in distribution: - if len(point) != 2: - continue - x = float(point[0]) - y = float(point[1]) - x = max(0.0, min(1.0, x)) - y = max(0.0, min(1.0, y)) - points.append((x, y)) - self.points = self._normalize_points(points) - self.redraw() - except (TypeError, ValueError): - self.points = [] - - def clear(self): - self.points = [] - self.canvas.delete("distribution") - - def apply(self): - if not self.points: - self.owner.distribution = None - self.destroy() - return - distribution = self._resample_distribution() - if not distribution: - self.owner.distribution = None - else: - self.owner.distribution = distribution - self.destroy() - - def _resample_distribution(self): - """ - Convert the freehand line into a fixed number of evenly spaced - points. This makes the saved data compact and predictable. - """ - points = self._normalize_points(self.points) - if len(points) < 2: - return None - result = [] - for index in range(self.SAMPLE_COUNT): - x = index / (self.SAMPLE_COUNT - 1) - y = self._interpolate(points, x) - result.append([round(x, 6), round(max(0.0, min(1.0, y)), 6)]) - # If the entire drawing is at zero, there is no distribution. - if max(point[1] for point in result) <= 0: - return None - return result - - @staticmethod - def _interpolate(points, x): - if x <= points[0][0]: - return points[0][1] - if x >= points[-1][0]: - return points[-1][1] - for index in range(1, len(points)): - x1, y1 = points[index - 1] - x2, y2 = points[index] - if x <= x2: - if x2 == x1: - return y2 - amount = (x - x1) / (x2 - x1) - return y1 + ((y2 - y1) * amount) - return points[-1][1] - - @staticmethod - def _format_number(value): - if float(value).is_integer(): - return str(int(value)) - return str(value) \ No newline at end of file diff --git a/src/windows/others/__init__.py b/src/windows/others/__init__.py index 276a6f2..3cc3b20 100644 --- a/src/windows/others/__init__.py +++ b/src/windows/others/__init__.py @@ -1 +1,2 @@ from .new_ver_avalaible import NewVerAvailable +from .distribution import DistributionDrawer \ No newline at end of file diff --git a/src/windows/others/distribution.py b/src/windows/others/distribution.py new file mode 100644 index 0000000..301ce36 --- /dev/null +++ b/src/windows/others/distribution.py @@ -0,0 +1,257 @@ +from tkinter import BOTTOM, LEFT, RIGHT, TOP, Canvas +from tkinter.ttk import Button, Frame, Label +from windows.popup import Popup + +class DistributionDrawer(Popup): + """ + Simple visual distribution editor. + + The user draws a curve: + higher = delay happens more often + lower = delay happens less often + + Distribution is stored as normalized points: + + [ + [0.0, 0.0], + [0.1, 0.2], + [0.2, 0.5], + ... + [1.0, 0.0] + ] + + X is the position between Lower and Upper. + Y is how common that value should be. + """ + + CANVAS_WIDTH = 600 + CANVAS_HEIGHT = 300 + SAMPLE_COUNT = 128 + + def __init__(self, parent, main_app, lower_bound, upper_bound, distribution=None): + text = main_app.text_content["options_menu"]["playback_menu"]["randomized_delay_settings"] + super().__init__(main_app.text_content["options_menu"]["playback_menu"]["distribution_drawer_settings"]["title"], 680, 470, parent) + self.owner = parent + self.main_app = main_app + self.lower_bound = lower_bound + self.upper_bound = upper_bound + self.points = [] + Label(self, text=main_app.text_content["options_menu"]["playback_menu"]["distribution_drawer_settings"]["sub_text"], font=("Segoe UI", 10)).pack(side=TOP, pady=(10, 8)) + + graph_frame = Frame(self) + graph_frame.pack(padx=15, pady=5) + + Label(graph_frame, text=main_app.text_content["options_menu"]["playback_menu"]["distribution_drawer_settings"]["more_text"]).pack(side=TOP, pady=(0, 2)) + + canvas_frame = Frame(graph_frame, relief="solid", borderwidth=1) + canvas_frame.pack() + + self.canvas = Canvas(canvas_frame, width=self.CANVAS_WIDTH, height=self.CANVAS_HEIGHT, highlightthickness=0) + self.canvas.pack() + + self._draw_grid() + + info_area = Frame(graph_frame) + Label(info_area, text=self._format_number(lower_bound)).pack(side=LEFT, padx=(0, 250)) + Label(info_area, text=self._format_number(upper_bound)).pack(side=RIGHT, padx=(250, 0)) + info_area.pack(fill="x", pady=(4, 0)) + + Label(graph_frame, text=main_app.text_content["options_menu"]["playback_menu"]["distribution_drawer_settings"]["less_text"]).pack(side=TOP, pady=(2, 0)) + + button_area = Frame(self) + Button(button_area, text=main_app.text_content["options_menu"]["playback_menu"]["distribution_drawer_settings"]["clear_text"], command=self.clear).pack(side=LEFT, padx=10) + Button(button_area, text=main_app.text_content["global"]["confirm_button"], command=self.apply).pack(side=LEFT, padx=10) + Button(button_area, text=main_app.text_content["global"]["cancel_button"], command=self.destroy).pack(side=LEFT, padx=10) + button_area.pack(side=BOTTOM, pady=10) + + self.canvas.bind("", self.start_drawing) + self.canvas.bind("", self.draw) + self.canvas.bind("", self.finish_drawing) + # Draw the existing distribution, if one exists. + self.load_distribution(distribution) + self.update_idletasks() + popup_width = min(max(680, self.winfo_reqwidth() + 10), 900) + popup_height = min(max(470, self.winfo_reqheight() + 10), 700) + self.geometry(f"{popup_width}x{popup_height}") + self.wait_window() + + # def __init__(self, parent, main_app, lower_bound, upper_bound, distribution=None): + # text = main_app.text_content["options_menu"]["playback_menu"]["randomized_delay_settings"] + # super().__init__(main_app.text_content["options_menu"]["playback_menu"]["randomized_delay_settings"]["title"], 640, 430, parent) + # self.owner = parent + # self.main_app = main_app + # self.lower_bound = lower_bound + # self.upper_bound = upper_bound + # self.points = [] + # Label(self, text=main_app.text_content["options_menu"]["playback_menu"]["distribution_drawer_settings"]["title"], font=("Segoe UI", 10)).pack(side=TOP, pady=(10, 4)) + # canvas_frame = Frame(self) + # self.canvas = Canvas(canvas_frame, width=self.CANVAS_WIDTH, height=self.CANVAS_HEIGHT, highlightthickness=1) + # self.canvas.pack() + # canvas_frame.pack(padx=10, pady=5) + # info_area = Frame(self) + # Label(info_area, text=main_app.text_content["options_menu"]["playback_menu"]["distribution_drawer_settings"]["more_text"]).pack(side=TOP) + # Label(info_area, text=f"◄\t{self._format_number(lower_bound)} | {self._format_number(upper_bound)} ►").pack(side=TOP) + # Label(info_area, text=main_app.text_content["options_menu"]["playback_menu"]["distribution_drawer_settings"]["less_text"]).pack(side=TOP) + # info_area.pack(pady=2) + # button_area = Frame(self) + # Button(button_area, text=main_app.text_content["options_menu"]["playback_menu"]["distribution_drawer_settings"]["clear_text"], command=self.clear).pack(side=LEFT, padx=10) + # Button(button_area, text=main_app.text_content["global"]["confirm_button"], command=self.apply).pack(side=LEFT, padx=10) + # Button(button_area, text=main_app.text_content["global"]["cancel_button"], command=self.destroy).pack(side=LEFT, padx=10) + # button_area.pack(side=BOTTOM, pady=10) + # self.canvas.bind("", self.start_drawing) + # self.canvas.bind("", self.draw) + # self.canvas.bind("", self.finish_drawing) + # # Draw the existing distribution, if one exists. + # self.load_distribution(distribution) + # self.update_idletasks() + # popup_width = min(max(640, self.winfo_reqwidth() + 10), 900) + # popup_height = min(max(430, self.winfo_reqheight() + 10), 700) + # self.geometry(f"{popup_width}x{popup_height}") + # self.wait_window() + + def start_drawing(self, event): + self.points = [(self._canvas_to_normalized_x(event.x), self._canvas_to_normalized_y(event.y))] + self.redraw() + + def draw(self, event): + x = self._canvas_to_normalized_x(event.x) + y = self._canvas_to_normalized_y(event.y) + self.points.append((x, y)) + self.redraw() + + def finish_drawing(self, event): + x = self._canvas_to_normalized_x(event.x) + y = self._canvas_to_normalized_y(event.y) + + self.points.append((x, y)) + self.points = self._normalize_points(self.points) + self.redraw() + + def _canvas_to_normalized_x(self, x): + x = max(0, min(self.CANVAS_WIDTH, x)) + return x / self.CANVAS_WIDTH + + def _canvas_to_normalized_y(self, y): + y = max(0, min(self.CANVAS_HEIGHT, y)) + return 1.0 - (y / self.CANVAS_HEIGHT) + + def _normalized_to_canvas(self, x, y): + return (x * self.CANVAS_WIDTH, (1.0 - y) * self.CANVAS_HEIGHT) + + def _draw_grid(self): + for x in range(0, self.CANVAS_WIDTH + 1, self.CANVAS_WIDTH // 10): + self.canvas.create_line(x, 0, x, self.CANVAS_HEIGHT, fill="#d9d9d9", tags="grid") + for y in range(0, self.CANVAS_HEIGHT + 1, self.CANVAS_HEIGHT // 10): + self.canvas.create_line(0, y, self.CANVAS_WIDTH, y, fill="#d9d9d9", tags="grid") + + def _normalize_points(self, points): + """ + Convert arbitrary mouse input into one point per X position. + + This means the saved distribution isn't tied to screen pixels. + """ + if not points: + return [] + cleaned = [] + for x, y in points: + x = max(0.0, min(1.0, float(x))) + y = max(0.0, min(1.0, float(y))) + cleaned.append((x, y)) + cleaned.sort(key=lambda point: point[0]) + # Merge points that have effectively the same X. + merged = [] + for x, y in cleaned: + if merged and abs(merged[-1][0] - x) < 0.002: + old_x, old_y = merged[-1] + merged[-1] = ((old_x + x) / 2, (old_y + y) / 2) + else: + merged.append((x, y)) + return merged + + def redraw(self): + self.canvas.delete("distribution") + if len(self.points) < 1: + return + coords = [] + for x, y in self.points: + canvas_x, canvas_y = self._normalized_to_canvas(x, y) + coords.extend((canvas_x, canvas_y)) + if len(coords) >= 4: + self.canvas.create_line(*coords, width=3, smooth=True, tags="distribution") + else: + x, y = self._normalized_to_canvas(self.points[0][0], self.points[0][1]) + self.canvas.create_oval(x - 2, y - 2, x + 2, y + 2, tags="distribution") + + def load_distribution(self, distribution): + if not distribution: + return + try: + points = [] + for point in distribution: + if len(point) != 2: + continue + x = float(point[0]) + y = float(point[1]) + x = max(0.0, min(1.0, x)) + y = max(0.0, min(1.0, y)) + points.append((x, y)) + self.points = self._normalize_points(points) + self.redraw() + except (TypeError, ValueError): + self.points = [] + + def clear(self): + self.points = [] + self.canvas.delete("distribution") + + def apply(self): + if not self.points: + self.owner.distribution = None + self.destroy() + return + distribution = self._resample_distribution() + if not distribution: + self.owner.distribution = None + else: + self.owner.distribution = distribution + self.destroy() + + def _resample_distribution(self): + """ + Convert the freehand line into a fixed number of evenly spaced + points. This makes the saved data compact and predictable. + """ + points = self._normalize_points(self.points) + if len(points) < 2: + return None + result = [] + for index in range(self.SAMPLE_COUNT): + x = index / (self.SAMPLE_COUNT - 1) + y = self._interpolate(points, x) + result.append([round(x, 6), round(max(0.0, min(1.0, y)), 6)]) + # If the entire drawing is at zero, there is no distribution. + if max(point[1] for point in result) <= 0: + return None + return result + + @staticmethod + def _interpolate(points, x): + if x <= points[0][0]: + return points[0][1] + if x >= points[-1][0]: + return points[-1][1] + for index in range(1, len(points)): + x1, y1 = points[index - 1] + x2, y2 = points[index] + if x <= x2: + if x2 == x1: + return y2 + amount = (x - x1) / (x2 - x1) + return y1 + ((y2 - y1) * amount) + return points[-1][1] + + @staticmethod + def _format_number(value): + if float(value).is_integer(): + return str(int(value)) + return str(value) From bf9a9c2fd379ce25500a5b01f8009e0ee33e8d90 Mon Sep 17 00:00:00 2001 From: Noah-Jaffe Date: Sun, 13 Sep 2026 11:44:49 -0400 Subject: [PATCH 11/16] add missing defaults --- src/utils/user_settings.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/utils/user_settings.py b/src/utils/user_settings.py index f503c0a..01eec5b 100644 --- a/src/utils/user_settings.py +++ b/src/utils/user_settings.py @@ -42,6 +42,7 @@ def init_settings(self): "Enabled": False, "Lower": 0, "Upper": 0, + "Distribution": [] }, "Repeat": { "Times": 1, @@ -85,7 +86,7 @@ def init_settings(self): "After_Playback": { "Mode": "Idle" - # Quit, Lock Computer, Lof off computer, Turn off computer, Restart Computer, Standby, Hibernate + # Quit, Lock Computer, Log off computer, Turn off computer, Restart Computer, Standby, Hibernate }, "Language": "en", @@ -157,6 +158,7 @@ def check_new_options(self): "Enabled": False, "Lower": 0, "Upper": 0, + "Distribution": [] } else: randomized_delay = userSettings["Playback"]["Randomized_Delay"] From 8f0f5d61fe43feb4730ee9463b7559beb40e48aa Mon Sep 17 00:00:00 2001 From: Noah-Jaffe Date: Sun, 13 Sep 2026 12:11:30 -0400 Subject: [PATCH 12/16] add new presets, erasing, variable number of points --- src/windows/others/distribution.py | 463 ++++++++++++++++++++++------- 1 file changed, 363 insertions(+), 100 deletions(-) diff --git a/src/windows/others/distribution.py b/src/windows/others/distribution.py index 301ce36..268f4a8 100644 --- a/src/windows/others/distribution.py +++ b/src/windows/others/distribution.py @@ -1,132 +1,332 @@ +import math +import tkinter as tk from tkinter import BOTTOM, LEFT, RIGHT, TOP, Canvas -from tkinter.ttk import Button, Frame, Label +from tkinter.ttk import Button, Combobox, Frame, Label, Spinbox + from windows.popup import Popup + class DistributionDrawer(Popup): """ - Simple visual distribution editor. - - The user draws a curve: - higher = delay happens more often - lower = delay happens less often - - Distribution is stored as normalized points: + Visual distribution editor. - [ - [0.0, 0.0], - [0.1, 0.2], - [0.2, 0.5], - ... - [1.0, 0.0] - ] + The curve is stored as normalized points: + [x, y] X is the position between Lower and Upper. - Y is how common that value should be. + Y is how common / likely that value should be. + + Editing is non-destructive: drawing over an existing curve only replaces + the portion of the curve covered by the new stroke. Right-click dragging + erases the covered portion. """ CANVAS_WIDTH = 600 CANVAS_HEIGHT = 300 - SAMPLE_COUNT = 128 + DEFAULT_SAMPLE_COUNT = 128 + MIN_SAMPLE_COUNT = 1 + MAX_SAMPLE_COUNT = 4096 + EDIT_RADIUS_X = 0.012 + ERASE_RADIUS_X = 0.018 + + PRESETS = { + "Freehand": "freehand", + "Linear": "linear", + "Binomial": "binomial", + "Logarithmic": "logarithmic", + "Ex-Gaussian": "ex_gaussian", + } def __init__(self, parent, main_app, lower_bound, upper_bound, distribution=None): - text = main_app.text_content["options_menu"]["playback_menu"]["randomized_delay_settings"] - super().__init__(main_app.text_content["options_menu"]["playback_menu"]["distribution_drawer_settings"]["title"], 680, 470, parent) self.owner = parent self.main_app = main_app self.lower_bound = lower_bound self.upper_bound = upper_bound self.points = [] - Label(self, text=main_app.text_content["options_menu"]["playback_menu"]["distribution_drawer_settings"]["sub_text"], font=("Segoe UI", 10)).pack(side=TOP, pady=(10, 8)) + self._stroke = [] + self._editing = False + self._erasing = False + + settings = main_app.text_content["options_menu"]["playback_menu"]["distribution_drawer_settings"] + title = settings.get("title", "Distribution Editor") + super().__init__(title, 900, 500, parent) + + Label( + self, + text=settings.get( + "sub_text", + "Draw the relative frequency of each value. Higher means more common.", + ), + font=("Segoe UI", 10), + ).pack(side=TOP, pady=(10, 8)) - graph_frame = Frame(self) - graph_frame.pack(padx=15, pady=5) + content = Frame(self) + content.pack(fill="both", expand=True, padx=15, pady=(0, 5)) - Label(graph_frame, text=main_app.text_content["options_menu"]["playback_menu"]["distribution_drawer_settings"]["more_text"]).pack(side=TOP, pady=(0, 2)) + graph_frame = Frame(content) + graph_frame.pack(side=LEFT, fill="both", expand=True) + + Label(graph_frame, text="Higher frequency / more common").pack(side=TOP, pady=(0, 2)) canvas_frame = Frame(graph_frame, relief="solid", borderwidth=1) canvas_frame.pack() - self.canvas = Canvas(canvas_frame, width=self.CANVAS_WIDTH, height=self.CANVAS_HEIGHT, highlightthickness=0) + self.canvas = Canvas( + canvas_frame, + width=self.CANVAS_WIDTH, + height=self.CANVAS_HEIGHT, + highlightthickness=0, + cursor="crosshair", + ) self.canvas.pack() - self._draw_grid() info_area = Frame(graph_frame) - Label(info_area, text=self._format_number(lower_bound)).pack(side=LEFT, padx=(0, 250)) - Label(info_area, text=self._format_number(upper_bound)).pack(side=RIGHT, padx=(250, 0)) + Label(info_area, text=f"Lower: {self._format_number(lower_bound)}").pack(side=LEFT) + Label(info_area, text=f"Upper: {self._format_number(upper_bound)}").pack(side=RIGHT) info_area.pack(fill="x", pady=(4, 0)) - Label(graph_frame, text=main_app.text_content["options_menu"]["playback_menu"]["distribution_drawer_settings"]["less_text"]).pack(side=TOP, pady=(2, 0)) - - button_area = Frame(self) - Button(button_area, text=main_app.text_content["options_menu"]["playback_menu"]["distribution_drawer_settings"]["clear_text"], command=self.clear).pack(side=LEFT, padx=10) - Button(button_area, text=main_app.text_content["global"]["confirm_button"], command=self.apply).pack(side=LEFT, padx=10) - Button(button_area, text=main_app.text_content["global"]["cancel_button"], command=self.destroy).pack(side=LEFT, padx=10) - button_area.pack(side=BOTTOM, pady=10) + Label(graph_frame, text="Lower frequency / less common").pack(side=TOP, pady=(2, 0)) + Label( + graph_frame, + text="Left-drag to edit • Right-drag to erase", + foreground="#666666", + ).pack(side=TOP, pady=(6, 0)) + + control_frame = Frame(content, relief="solid", borderwidth=1, padding=10) + control_frame.pack(side=RIGHT, fill="y", padx=(15, 0)) + + Label(control_frame, text="Curve controls", font=("Segoe UI", 10, "bold")).pack( + anchor="w", pady=(0, 10) + ) + + Label(control_frame, text="Preset").pack(anchor="w") + self.preset_var = tk.StringVar(value="Freehand") + self.preset_box = Combobox( + control_frame, + textvariable=self.preset_var, + values=list(self.PRESETS.keys()), + state="readonly", + width=18, + ) + self.preset_box.pack(fill="x", pady=(2, 2)) + self.preset_box.bind("<>", self.apply_selected_preset) + self._add_tooltip( + self.preset_box, + "Choose a common curve shape. Selecting a preset replaces the current curve.", + ) + + self.preset_description = Label( + control_frame, + text=self._preset_description("Freehand"), + wraplength=180, + justify="left", + foreground="#555555", + ) + self.preset_description.pack(anchor="w", pady=(0, 12)) + + Label(control_frame, text="Number of samples / steps").pack(anchor="w") + self.steps_var = tk.StringVar(value=str(self.DEFAULT_SAMPLE_COUNT)) + self.steps_spinbox = Spinbox( + control_frame, + from_=self.MIN_SAMPLE_COUNT, + to=self.MAX_SAMPLE_COUNT, + increment=1, + textvariable=self.steps_var, + width=10, + validate="key", + validatecommand=(self.register(self._validate_steps), "%P"), + ) + self.steps_spinbox.pack(anchor="w", pady=(2, 2)) + self._add_tooltip( + self.steps_spinbox, + "Controls how many evenly spaced points are saved when you apply the curve (1–4096).", + ) + + Button( + control_frame, + text="Interpolate missing values", + command=self.interpolate_missing, + ).pack(fill="x", pady=(10, 4)) + self._add_tooltip( + control_frame.winfo_children()[-1], + "Fills gaps in the current drawing using straight-line interpolation and immediately redraws it.", + ) + + Button(control_frame, text="Clear drawing", command=self.clear).pack(fill="x", pady=4) + self._add_tooltip( + control_frame.winfo_children()[-1], + "Remove the entire curve. This is separate from right-drag erasing, which only removes a selected area.", + ) + + # The bottom row is intentionally reserved for the popup actions only. + bottom_frame = Frame(self) + bottom_frame.pack(side=BOTTOM, fill="x", pady=(5, 10)) + Button( + bottom_frame, + text=main_app.text_content["global"].get("cancel_button", "Cancel"), + command=self.destroy, + ).pack(side=RIGHT, padx=(5, 15)) + Button( + bottom_frame, + text=main_app.text_content["global"].get("confirm_button", "Apply"), + command=self.apply, + ).pack(side=RIGHT, padx=5) self.canvas.bind("", self.start_drawing) self.canvas.bind("", self.draw) self.canvas.bind("", self.finish_drawing) - # Draw the existing distribution, if one exists. + self.canvas.bind("", self.start_erasing) + self.canvas.bind("", self.erase) + self.canvas.bind("", self.finish_erasing) + self.load_distribution(distribution) + self.update_idletasks() - popup_width = min(max(680, self.winfo_reqwidth() + 10), 900) - popup_height = min(max(470, self.winfo_reqheight() + 10), 700) + popup_width = min(max(900, self.winfo_reqwidth() + 10), 1100) + popup_height = min(max(500, self.winfo_reqheight() + 10), 760) self.geometry(f"{popup_width}x{popup_height}") self.wait_window() - # def __init__(self, parent, main_app, lower_bound, upper_bound, distribution=None): - # text = main_app.text_content["options_menu"]["playback_menu"]["randomized_delay_settings"] - # super().__init__(main_app.text_content["options_menu"]["playback_menu"]["randomized_delay_settings"]["title"], 640, 430, parent) - # self.owner = parent - # self.main_app = main_app - # self.lower_bound = lower_bound - # self.upper_bound = upper_bound - # self.points = [] - # Label(self, text=main_app.text_content["options_menu"]["playback_menu"]["distribution_drawer_settings"]["title"], font=("Segoe UI", 10)).pack(side=TOP, pady=(10, 4)) - # canvas_frame = Frame(self) - # self.canvas = Canvas(canvas_frame, width=self.CANVAS_WIDTH, height=self.CANVAS_HEIGHT, highlightthickness=1) - # self.canvas.pack() - # canvas_frame.pack(padx=10, pady=5) - # info_area = Frame(self) - # Label(info_area, text=main_app.text_content["options_menu"]["playback_menu"]["distribution_drawer_settings"]["more_text"]).pack(side=TOP) - # Label(info_area, text=f"◄\t{self._format_number(lower_bound)} | {self._format_number(upper_bound)} ►").pack(side=TOP) - # Label(info_area, text=main_app.text_content["options_menu"]["playback_menu"]["distribution_drawer_settings"]["less_text"]).pack(side=TOP) - # info_area.pack(pady=2) - # button_area = Frame(self) - # Button(button_area, text=main_app.text_content["options_menu"]["playback_menu"]["distribution_drawer_settings"]["clear_text"], command=self.clear).pack(side=LEFT, padx=10) - # Button(button_area, text=main_app.text_content["global"]["confirm_button"], command=self.apply).pack(side=LEFT, padx=10) - # Button(button_area, text=main_app.text_content["global"]["cancel_button"], command=self.destroy).pack(side=LEFT, padx=10) - # button_area.pack(side=BOTTOM, pady=10) - # self.canvas.bind("", self.start_drawing) - # self.canvas.bind("", self.draw) - # self.canvas.bind("", self.finish_drawing) - # # Draw the existing distribution, if one exists. - # self.load_distribution(distribution) - # self.update_idletasks() - # popup_width = min(max(640, self.winfo_reqwidth() + 10), 900) - # popup_height = min(max(430, self.winfo_reqheight() + 10), 700) - # self.geometry(f"{popup_width}x{popup_height}") - # self.wait_window() + def _validate_steps(self, value): + if value == "": + return True + try: + return self.MIN_SAMPLE_COUNT <= int(value) <= self.MAX_SAMPLE_COUNT + except ValueError: + return False + + def _get_sample_count(self): + try: + value = int(self.steps_var.get()) + except ValueError: + value = self.DEFAULT_SAMPLE_COUNT + return max(self.MIN_SAMPLE_COUNT, min(self.MAX_SAMPLE_COUNT, value)) + + def _add_tooltip(self, widget, text): + tooltip = {"window": None} + + def show(_event=None): + if tooltip["window"] is not None: + return + x = widget.winfo_rootx() + 10 + y = widget.winfo_rooty() + widget.winfo_height() + 4 + window = tk.Toplevel(widget) + window.wm_overrideredirect(True) + window.geometry(f"+{x}+{y}") + Label( + window, + text=text, + justify="left", + wraplength=260, + relief="solid", + borderwidth=1, + padding=6, + ).pack() + tooltip["window"] = window + + def hide(_event=None): + if tooltip["window"] is not None: + tooltip["window"].destroy() + tooltip["window"] = None + + widget.bind("", show, add="+") + widget.bind("", hide, add="+") + + def _preset_description(self, name): + descriptions = { + "Freehand": "Draw any shape manually. Existing curve sections outside your stroke are preserved.", + "Linear": "A straight ramp from low frequency to high frequency across the range.", + "Binomial": "A bell-like discrete distribution, useful when outcomes cluster around a central value.", + "Logarithmic": "Changes quickly near the low end and more gradually toward the high end.", + "Ex-Gaussian": "A Gaussian-shaped peak with a longer tail, useful for skewed timing-like data.", + } + return descriptions.get(name, descriptions["Freehand"]) def start_drawing(self, event): - self.points = [(self._canvas_to_normalized_x(event.x), self._canvas_to_normalized_y(event.y))] + self._editing = True + self._erasing = False + self._stroke = [ + (self._canvas_to_normalized_x(event.x), self._canvas_to_normalized_y(event.y)) + ] self.redraw() def draw(self, event): - x = self._canvas_to_normalized_x(event.x) - y = self._canvas_to_normalized_y(event.y) - self.points.append((x, y)) + if not self._editing or self._erasing: + return + self._stroke.append( + (self._canvas_to_normalized_x(event.x), self._canvas_to_normalized_y(event.y)) + ) + self._merge_stroke_into_curve() self.redraw() def finish_drawing(self, event): - x = self._canvas_to_normalized_x(event.x) - y = self._canvas_to_normalized_y(event.y) + if not self._editing or self._erasing: + return + self._stroke.append( + (self._canvas_to_normalized_x(event.x), self._canvas_to_normalized_y(event.y)) + ) + self._merge_stroke_into_curve() + self._stroke = [] + self._editing = False + self.points = self._normalize_points(self.points) + self.redraw() + + def start_erasing(self, event): + self._editing = True + self._erasing = True + self._erase_at(event.x, event.y) + self.redraw() + + def erase(self, event): + if not self._editing or not self._erasing: + return + self._erase_at(event.x, event.y) + self.redraw() - self.points.append((x, y)) + def finish_erasing(self, _event): + self._stroke = [] + self._editing = False + self._erasing = False self.points = self._normalize_points(self.points) self.redraw() + def _merge_stroke_into_curve(self): + if not self._stroke: + return + stroke = self._normalize_points(self._stroke) + if not stroke: + return + stroke_min = stroke[0][0] + stroke_max = stroke[-1][0] + + if not self.points: + self.points = stroke + return + + left_y = self._interpolate(self.points, stroke_min) + right_y = self._interpolate(self.points, stroke_max) + merged = [(x, y) for x, y in self.points if x < stroke_min or x > stroke_max] + + if stroke_min > 0 and not any(abs(x - stroke_min) < 1e-6 for x, _ in merged): + merged.append((stroke_min, left_y)) + if stroke_max < 1 and not any(abs(x - stroke_max) < 1e-6 for x, _ in merged): + merged.append((stroke_max, right_y)) + + merged.extend(stroke) + self.points = self._normalize_points(merged) + + def _erase_at(self, canvas_x, canvas_y): + x = self._canvas_to_normalized_x(canvas_x) + y = self._canvas_to_normalized_y(canvas_y) + remaining = [] + for px, py in self.points: + x_distance = abs(px - x) + y_distance = abs(py - y) + if x_distance > self.ERASE_RADIUS_X or y_distance > 0.08: + remaining.append((px, py)) + self.points = remaining + def _canvas_to_normalized_x(self, x): x = max(0, min(self.CANVAS_WIDTH, x)) return x / self.CANVAS_WIDTH @@ -139,17 +339,13 @@ def _normalized_to_canvas(self, x, y): return (x * self.CANVAS_WIDTH, (1.0 - y) * self.CANVAS_HEIGHT) def _draw_grid(self): + self.canvas.delete("grid") for x in range(0, self.CANVAS_WIDTH + 1, self.CANVAS_WIDTH // 10): self.canvas.create_line(x, 0, x, self.CANVAS_HEIGHT, fill="#d9d9d9", tags="grid") for y in range(0, self.CANVAS_HEIGHT + 1, self.CANVAS_HEIGHT // 10): self.canvas.create_line(0, y, self.CANVAS_WIDTH, y, fill="#d9d9d9", tags="grid") def _normalize_points(self, points): - """ - Convert arbitrary mouse input into one point per X position. - - This means the saved distribution isn't tied to screen pixels. - """ if not points: return [] cleaned = [] @@ -158,7 +354,6 @@ def _normalize_points(self, points): y = max(0.0, min(1.0, float(y))) cleaned.append((x, y)) cleaned.sort(key=lambda point: point[0]) - # Merge points that have effectively the same X. merged = [] for x, y in cleaned: if merged and abs(merged[-1][0] - x) < 0.002: @@ -170,7 +365,7 @@ def _normalize_points(self, points): def redraw(self): self.canvas.delete("distribution") - if len(self.points) < 1: + if not self.points: return coords = [] for x, y in self.points: @@ -182,6 +377,13 @@ def redraw(self): x, y = self._normalized_to_canvas(self.points[0][0], self.points[0][1]) self.canvas.create_oval(x - 2, y - 2, x + 2, y + 2, tags="distribution") + # Keep a live preview of the current stroke while drawing. + if len(self._stroke) >= 2: + stroke_coords = [] + for x, y in self._stroke: + stroke_coords.extend(self._normalized_to_canvas(x, y)) + self.canvas.create_line(*stroke_coords, width=3, dash=(5, 3), tags="distribution") + def load_distribution(self, distribution): if not distribution: return @@ -190,10 +392,8 @@ def load_distribution(self, distribution): for point in distribution: if len(point) != 2: continue - x = float(point[0]) - y = float(point[1]) - x = max(0.0, min(1.0, x)) - y = max(0.0, min(1.0, y)) + x = max(0.0, min(1.0, float(point[0]))) + y = max(0.0, min(1.0, float(point[1]))) points.append((x, y)) self.points = self._normalize_points(points) self.redraw() @@ -202,40 +402,103 @@ def load_distribution(self, distribution): def clear(self): self.points = [] - self.canvas.delete("distribution") + self._stroke = [] + self.redraw() + + def apply_selected_preset(self, _event=None): + name = self.preset_var.get() + self.preset_description.config(text=self._preset_description(name)) + if name == "Freehand": + return + self.points = self._make_preset(self.PRESETS[name], self._get_sample_count()) + self._stroke = [] + self.redraw() + + def _make_preset(self, preset, sample_count): + if sample_count <= 1: + sample_count = 2 + raw = [] + for index in range(sample_count): + x = index / (sample_count - 1) + if preset == "linear": + y = x + elif preset == "binomial": + # Symmetric binomial PMF sampled across a continuous x range. + n = 12 + k = round(n * x) + pmf = math.comb(n, k) * (0.5 ** n) + y = pmf + elif preset == "logarithmic": + y = math.log1p(9.0 * x) / math.log(10.0) + elif preset == "ex_gaussian": + y = self._ex_gaussian_pdf(x, mu=0.42, sigma=0.13, rate=5.5) + else: + y = 0.0 + raw.append((x, y)) + + max_y = max(y for _, y in raw) if raw else 0.0 + if max_y > 0: + raw = [(x, y / max_y) for x, y in raw] + return raw + + @staticmethod + def _ex_gaussian_pdf(x, mu, sigma, rate): + value = (rate / 2.0) * math.exp( + rate * (mu - x) + (rate * rate * sigma * sigma) / 2.0 + ) * math.erfc( + (mu + (rate * sigma * sigma) - x) / (math.sqrt(2.0) * sigma) + ) + return max(0.0, value) + + def interpolate_missing(self): + """ + Make the current drawing continuous at the selected number of steps. + The interpolated values replace the visible drawing immediately. + """ + if len(self.points) < 2: + return + + sample_count = self._get_sample_count() + result = [] + for index in range(sample_count): + x = index / (sample_count - 1) if sample_count > 1 else 0.0 + y = self._interpolate(self.points, x) + result.append((x, max(0.0, min(1.0, y)))) + self.points = result + self.redraw() def apply(self): if not self.points: self.owner.distribution = None self.destroy() return + distribution = self._resample_distribution() - if not distribution: - self.owner.distribution = None - else: - self.owner.distribution = distribution + self.owner.distribution = distribution self.destroy() def _resample_distribution(self): - """ - Convert the freehand line into a fixed number of evenly spaced - points. This makes the saved data compact and predictable. - """ points = self._normalize_points(self.points) if len(points) < 2: return None + + sample_count = self._get_sample_count() result = [] - for index in range(self.SAMPLE_COUNT): - x = index / (self.SAMPLE_COUNT - 1) + for index in range(sample_count): + x = index / (sample_count - 1) if sample_count > 1 else 0.0 y = self._interpolate(points, x) result.append([round(x, 6), round(max(0.0, min(1.0, y)), 6)]) - # If the entire drawing is at zero, there is no distribution. + if max(point[1] for point in result) <= 0: return None return result @staticmethod def _interpolate(points, x): + if not points: + return 0.0 + if len(points) == 1: + return points[0][1] if x <= points[0][0]: return points[0][1] if x >= points[-1][0]: From 652b59bd618f4ea1e49977beaefededdc04eb7e5 Mon Sep 17 00:00:00 2001 From: Noah-Jaffe Date: Sun, 13 Sep 2026 12:35:32 -0400 Subject: [PATCH 13/16] add uniform option, and also add colors for drawn vs interp --- src/windows/others/distribution.py | 97 +++++++++++++++++++++++------- 1 file changed, 75 insertions(+), 22 deletions(-) diff --git a/src/windows/others/distribution.py b/src/windows/others/distribution.py index 268f4a8..ba4ff31 100644 --- a/src/windows/others/distribution.py +++ b/src/windows/others/distribution.py @@ -29,9 +29,13 @@ class DistributionDrawer(Popup): EDIT_RADIUS_X = 0.012 ERASE_RADIUS_X = 0.018 + USER_POINT_COLOR = "#4a890b" + INTERPOLATED_COLOR = "#4a0b89" + PRESETS = { "Freehand": "freehand", "Linear": "linear", + "Uniform": "uniform", "Binomial": "binomial", "Logarithmic": "logarithmic", "Ex-Gaussian": "ex_gaussian", @@ -43,6 +47,7 @@ def __init__(self, parent, main_app, lower_bound, upper_bound, distribution=None self.lower_bound = lower_bound self.upper_bound = upper_bound self.points = [] + self._user_points = [] self._stroke = [] self._editing = False self._erasing = False @@ -158,7 +163,6 @@ def __init__(self, parent, main_app, lower_bound, upper_bound, distribution=None control_frame.winfo_children()[-1], "Remove the entire curve. This is separate from right-drag erasing, which only removes a selected area.", ) - # The bottom row is intentionally reserved for the popup actions only. bottom_frame = Frame(self) bottom_frame.pack(side=BOTTOM, fill="x", pady=(5, 10)) @@ -237,6 +241,7 @@ def _preset_description(self, name): descriptions = { "Freehand": "Draw any shape manually. Existing curve sections outside your stroke are preserved.", "Linear": "A straight ramp from low frequency to high frequency across the range.", + "Uniform": "A uniform distribution across the range.", "Binomial": "A bell-like discrete distribution, useful when outcomes cluster around a central value.", "Logarithmic": "Changes quickly near the low end and more gradually toward the high end.", "Ex-Gaussian": "A Gaussian-shaped peak with a longer tail, useful for skewed timing-like data.", @@ -270,6 +275,7 @@ def finish_drawing(self, event): self._stroke = [] self._editing = False self.points = self._normalize_points(self.points) + self._user_points = self._normalize_points(self._user_points) self.redraw() def start_erasing(self, event): @@ -289,6 +295,7 @@ def finish_erasing(self, _event): self._editing = False self._erasing = False self.points = self._normalize_points(self.points) + self._user_points = self._normalize_points(self._user_points) self.redraw() def _merge_stroke_into_curve(self): @@ -302,6 +309,7 @@ def _merge_stroke_into_curve(self): if not self.points: self.points = stroke + self._user_points = stroke.copy() return left_y = self._interpolate(self.points, stroke_min) @@ -316,6 +324,14 @@ def _merge_stroke_into_curve(self): merged.extend(stroke) self.points = self._normalize_points(merged) + self._user_points = [ + (x, y) + for x, y in self._user_points + if x < stroke_min or x > stroke_max + ] + self._user_points.extend(stroke) + self._user_points = self._normalize_points(self._user_points) + def _erase_at(self, canvas_x, canvas_y): x = self._canvas_to_normalized_x(canvas_x) y = self._canvas_to_normalized_y(canvas_y) @@ -327,6 +343,14 @@ def _erase_at(self, canvas_x, canvas_y): remaining.append((px, py)) self.points = remaining + remaining_user = [] + for px, py in self._user_points: + x_distance = abs(px - x) + y_distance = abs(py - y) + if x_distance > self.ERASE_RADIUS_X or y_distance > 0.08: + remaining_user.append((px, py)) + self._user_points = remaining_user + def _canvas_to_normalized_x(self, x): x = max(0, min(self.CANVAS_WIDTH, x)) return x / self.CANVAS_WIDTH @@ -363,26 +387,47 @@ def _normalize_points(self, points): merged.append((x, y)) return merged + def _is_user_point(self, point): + x, y = point + return any(abs(user_x - x) < 0.003 and abs(user_y - y) < 0.003 for user_x, user_y in self._user_points) + def redraw(self): self.canvas.delete("distribution") - if not self.points: - return - coords = [] - for x, y in self.points: - canvas_x, canvas_y = self._normalized_to_canvas(x, y) - coords.extend((canvas_x, canvas_y)) - if len(coords) >= 4: - self.canvas.create_line(*coords, width=3, smooth=True, tags="distribution") - else: - x, y = self._normalized_to_canvas(self.points[0][0], self.points[0][1]) - self.canvas.create_oval(x - 2, y - 2, x + 2, y + 2, tags="distribution") - - # Keep a live preview of the current stroke while drawing. + if self.points: + coords = [] + for x, y in self.points: + canvas_x, canvas_y = self._normalized_to_canvas(x, y) + coords.extend((canvas_x, canvas_y)) + if len(coords) >= 4: + self.canvas.create_line(*coords, width=3, smooth=True, fill=self.INTERPOLATED_COLOR, tags="distribution") + else: + x, y = self._normalized_to_canvas(self.points[0][0], self.points[0][1]) + self.canvas.create_oval(x - 2, y - 2, x + 2, y + 2, fill=self.USER_POINT_COLOR, outline=self.USER_POINT_COLOR, tags="distribution") + for x, y in self.points: + canvas_x, canvas_y = self._normalized_to_canvas(x, y) + if self._is_user_point((x, y)): + radius = 3 + self.canvas.create_oval( + canvas_x - radius, + canvas_y - radius, + canvas_x + radius, + canvas_y + radius, + fill=self.USER_POINT_COLOR, + outline=self.USER_POINT_COLOR, + tags="distribution", + ) + if len(self._stroke) >= 2: stroke_coords = [] for x, y in self._stroke: stroke_coords.extend(self._normalized_to_canvas(x, y)) - self.canvas.create_line(*stroke_coords, width=3, dash=(5, 3), tags="distribution") + + self.canvas.create_line(*stroke_coords, width=3, dash=(5, 3), fill=self.USER_POINT_COLOR, tags="distribution") + + for x, y in self._stroke: + canvas_x, canvas_y = self._normalized_to_canvas(x, y) + radius = 3 + self.canvas.create_oval(canvas_x - radius, canvas_y - radius, canvas_x + radius, canvas_y + radius, fill=self.USER_POINT_COLOR, outline=self.USER_POINT_COLOR, tags="distribution") def load_distribution(self, distribution): if not distribution: @@ -396,12 +441,15 @@ def load_distribution(self, distribution): y = max(0.0, min(1.0, float(point[1]))) points.append((x, y)) self.points = self._normalize_points(points) + self._user_points = self.points.copy() self.redraw() except (TypeError, ValueError): self.points = [] + self._user_points = [] def clear(self): self.points = [] + self._user_points = [] self._stroke = [] self.redraw() @@ -411,6 +459,7 @@ def apply_selected_preset(self, _event=None): if name == "Freehand": return self.points = self._make_preset(self.PRESETS[name], self._get_sample_count()) + self._user_points = self.points.copy() self._stroke = [] self.redraw() @@ -422,8 +471,14 @@ def _make_preset(self, preset, sample_count): x = index / (sample_count - 1) if preset == "linear": y = x + elif preset == "uniform": + if x == 0: + y = 0 + elif x == 1: + y = 1 + else: + y = 0.5 elif preset == "binomial": - # Symmetric binomial PMF sampled across a continuous x range. n = 12 k = round(n * x) pmf = math.comb(n, k) * (0.5 ** n) @@ -431,7 +486,7 @@ def _make_preset(self, preset, sample_count): elif preset == "logarithmic": y = math.log1p(9.0 * x) / math.log(10.0) elif preset == "ex_gaussian": - y = self._ex_gaussian_pdf(x, mu=0.42, sigma=0.13, rate=5.5) + y = self._ex_gaussian_pdf(x, mu=0.62, sigma=0.10, rate=3.0) else: y = 0.0 raw.append((x, y)) @@ -443,11 +498,7 @@ def _make_preset(self, preset, sample_count): @staticmethod def _ex_gaussian_pdf(x, mu, sigma, rate): - value = (rate / 2.0) * math.exp( - rate * (mu - x) + (rate * rate * sigma * sigma) / 2.0 - ) * math.erfc( - (mu + (rate * sigma * sigma) - x) / (math.sqrt(2.0) * sigma) - ) + value = (rate / 2.0) * math.exp(rate * (mu - x) + (rate * rate * sigma * sigma) / 2.0) * math.erfc((mu + (rate * sigma * sigma) - x) / (math.sqrt(2.0) * sigma)) return max(0.0, value) def interpolate_missing(self): @@ -464,7 +515,9 @@ def interpolate_missing(self): x = index / (sample_count - 1) if sample_count > 1 else 0.0 y = self._interpolate(self.points, x) result.append((x, max(0.0, min(1.0, y)))) + # Preserve the existing user-selected points so they remain green. self.points = result + self._user_points = self._normalize_points(self._user_points) self.redraw() def apply(self): From 378734b0c67215a640ac723a9019dcb2df1445f1 Mon Sep 17 00:00:00 2001 From: Noah-Jaffe Date: Sun, 13 Sep 2026 12:35:54 -0400 Subject: [PATCH 14/16] bugfix: improper import --- src/windows/options/playback/randomized_delay.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/windows/options/playback/randomized_delay.py b/src/windows/options/playback/randomized_delay.py index 0256868..5a3c89c 100644 --- a/src/windows/options/playback/randomized_delay.py +++ b/src/windows/options/playback/randomized_delay.py @@ -1,7 +1,7 @@ from tkinter import BOTTOM, LEFT, TOP, Spinbox, messagebox from tkinter.ttk import Button, Frame, Label from sys import maxsize as INT_BOUND -from src.windows.others.distribution import DistributionDrawer +from windows.others.distribution import DistributionDrawer from windows.popup import Popup class RandomizedDelay(Popup): From 0efd9804ccaaf883eb0ba74b8f6bbb4ba0df61e3 Mon Sep 17 00:00:00 2001 From: Noah-Jaffe Date: Sun, 13 Sep 2026 13:39:53 -0400 Subject: [PATCH 15/16] consolidate random related functions to new random.py file. implement proper continuous randomized value selection --- src/macro/macro.py | 39 ++------- src/utils/random.py | 207 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 216 insertions(+), 30 deletions(-) create mode 100644 src/utils/random.py diff --git a/src/macro/macro.py b/src/macro/macro.py index f54a95f..a5367d2 100644 --- a/src/macro/macro.py +++ b/src/macro/macro.py @@ -1,5 +1,4 @@ from datetime import datetime -import random from os import getlogin, system from sys import platform from threading import Thread @@ -10,22 +9,13 @@ from pynput.keyboard import Key # FUTURE SELF: DON'T REMOVE THIS!! from pynput.mouse import Button +from utils.random import generate_weighted_random_func, update_random_seed from utils.get_key_pressed import getKeyPressed from utils.keys import vk_nb from utils.record_file_management import RecordFileManagement from utils.show_toast import show_notification_minim from utils.warning_pop_up_save import confirm_save -# at arbitrary times, we re-seed randomness so that it is harder to reverse-engineer the random usage -def update_random_seed(seed=None, version=2): - """Update the random package seed. - - Args: - seed (int|float|str|bytes|bytearray, optional): The seed to be used. None will default to using system time. Defaults to None (aka, system time). - version (1|2, optional): the version for the generation of seeding algorithm, the higher means the more advanced. Defaults to the maximum valid value. - """ - random.seed(a=seed, version=version) - class Macro: """Init a new Macro""" @@ -220,6 +210,12 @@ def __play_events(self): repeat_count = 0 now = time() + randomized_delay_opts = userSettings["Playback"].get("Randomized_Delay", {}) + randomized_delay_weights = randomized_delay_opts.get("Distribution") + lower_bound = float(randomized_delay_opts["Lower"]) + upper_bound = float(randomized_delay_opts["Upper"]) + get_weighted_random = generate_weighted_random_func(randomized_delay_weights, lower_bound, upper_bound) + while self.playback and (is_infinite or repeat_count < repeat_times): for events in range(len(self.macro_events["events"])): elapsed_time = int(time() - now) @@ -239,25 +235,8 @@ def __play_events(self): # the speed adjustments only affect the base interval between actions, it should not affect other time modifiers adjusted_speed = 1/(userSettings["Playback"]["Speed"] or 1) # randomized delay between actions, default to zero for no change - randomized_delay_ms = None - randomized_delay_opts = userSettings["Playback"].get("Randomized_Delay", {}) - randomized_delay_weights = randomized_delay_opts.get("Distribution") - if randomized_delay_opts.get("Enabled", False): - lower_bound = float(randomized_delay_opts["Lower"]) - upper_bound = float(randomized_delay_opts["Upper"]) - - if lower_bound > upper_bound: - lower_bound, upper_bound = upper_bound, lower_bound - try: - randomized_delay_ms = random.choices(range(lower_bound, upper_bound, (upper_bound-lower_bound)/(len(randomized_delay_weights) or 1)), weights=randomized_delay_weights, k=1) - except Exception as e: - print(e) - pass - if randomized_delay_ms is None: - # default to linear/equal distribution if any issues occur - randomized_delay_ms = ((upper_bound - lower_bound) * random.random()) + lower_bound - - update_random_seed() + randomized_delay_ms = get_weighted_random() + update_random_seed() # sleep for the adjusted speed delay, and add the randomized delay time sleep_time = max(0, (base_ms * adjusted_speed) + ((randomized_delay_ms or 0)/1000)) diff --git a/src/utils/random.py b/src/utils/random.py new file mode 100644 index 0000000..5c40d66 --- /dev/null +++ b/src/utils/random.py @@ -0,0 +1,207 @@ +import math +import random + + +def update_random_seed(seed=None, version=2): + """Update the random package seed. + At arbitrary times the randomness should update its seed so that it is harder to reverse-engineer the randomized value usage. + + Args: + seed (int|float|str|bytes|bytearray, optional): The seed to be used. None will default to using system time. Defaults to None (aka, system time). + version (1|2, optional): the version for the generation of seeding algorithm, the higher means the more advanced. Defaults to the maximum valid value. + """ + random.seed(a=seed, version=version) + +def get_weighted_random_float(distribution, lower_bound=0, upper_bound=1): + """ + Select a continuous random float between lower_bound and upper_bound + according to the weighted distribution. + + distribution: + List of [x, weight] pairs, where x is normalized to [0, 1]. + Example: [[0.0, 1], [0.5, 5], [1.0, 2]] + + lower_bound, upper_bound: float + The lower and upper bounds (inclusive) for the range to choose a value from. + + The x positions define the shape of the distribution. The weights are + linearly interpolated between points to form a continuous probability + density function. + """ + if lower_bound > upper_bound: + lower_bound, upper_bound = upper_bound, lower_bound + if lower_bound == upper_bound: + return lower_bound + points = sorted((float(x), max(0.0, float(weight))) for x, weight in distribution) + if not points: + return random.uniform(lower_bound, upper_bound) + # Merge duplicate x positions. + merged = [] + for x, weight in points: + if merged and x == merged[-1][0]: + merged[-1][1] = weight + else: + merged.append([x, weight]) + points = merged + # Clamp the distribution to the usable normalized range. + if points[0][0] > 0.0: + points.insert(0, [0.0, points[0][1]]) + if points[-1][0] < 1.0: + points.append([1.0, points[-1][1]]) + points = [ + [max(0.0, min(1.0, x)), weight] + for x, weight in points + ] + # Calculate the area under the piecewise-linear probability density. + total_area = 0.0 + for i in range(len(points) - 1): + x1, y1 = points[i] + x2, y2 = points[i + 1] + total_area += (x2 - x1) * (y1 + y2) / 2.0 + if total_area <= 0.0: + return random.uniform(lower_bound, upper_bound) + # Select a random area under the density curve. + target_area = random.random() * total_area + accumulated_area = 0.0 + for i in range(len(points) - 1): + x1, y1 = points[i] + x2, y2 = points[i + 1] + width = x2 - x1 + if width <= 0.0: + continue + segment_area = width * (y1 + y2) / 2.0 + if accumulated_area + segment_area >= target_area: + # Solve for x within this piecewise-linear segment. + target = target_area - accumulated_area + slope = (y2 - y1) / width + if abs(slope) < 1e-12: + dx = target / y1 if y1 > 0.0 else 0.0 + else: + # Integral from 0 to dx: + # y1 * dx + 0.5 * slope * dx^2 = target + discriminant = y1 * y1 + 2.0 * slope * target + dx = (-y1 + math.sqrt(max(0.0, discriminant))) / slope + x = x1 + max(0.0, min(width, dx)) + return lower_bound + x * (upper_bound - lower_bound) + accumulated_area += segment_area + return upper_bound + +def generate_weighted_random_func(distribution, lower_bound=0.0, upper_bound=1.0): + """ + Create and return a callable that generates random floats between + lower_bound and upper_bound according to a weighted distribution. + + distribution: + List of [x, weight] pairs, where x is normalized to [0, 1]. + + Example: + [[0.0, 1], [0.5, 5], [1.0, 2]] + + The x positions define the shape of a piecewise-linear probability + density function. The weights are linearly interpolated between points. + + Example: + random_value = generate_weighted_random_func( + [[0.0, 1], [0.5, 5], [1.0, 2]], + lower_bound=10, + upper_bound=20, + ) + + value = random_value() + """ + + if lower_bound > upper_bound: + lower_bound, upper_bound = upper_bound, lower_bound + + lower_bound = float(lower_bound) + upper_bound = float(upper_bound) + + # Prepare the distribution once when creating the generator. + points = [ + ( + max(0.0, min(1.0, float(x))), + max(0.0, float(weight)), + ) + for x, weight in distribution + ] + + # If there is no usable distribution, use a uniform distribution. + if not points: + return lambda: random.uniform(lower_bound, upper_bound) + + # Sort and merge duplicate x positions. + points.sort(key=lambda point: point[0]) + + merged = [] + for x, weight in points: + if merged and x == merged[-1][0]: + merged[-1][1] = weight + else: + merged.append([x, weight]) + + points = merged + + # Extend the distribution to cover the full normalized range. + if points[0][0] > 0.0: + points.insert(0, [0.0, points[0][1]]) + + if points[-1][0] < 1.0: + points.append([1.0, points[-1][1]]) + + # Calculate the total area under the density curve. + total_area = sum( + (x2 - x1) * (y1 + y2) / 2.0 + for (x1, y1), (x2, y2) in zip(points, points[1:]) + ) + + # Fall back to uniform sampling if the density has no area. + if total_area <= 0.0: + return lambda: random.uniform(lower_bound, upper_bound) + + def sample(): + """Generate one random value using the prepared distribution.""" + + # Handle a zero-width output range. + if lower_bound == upper_bound: + return lower_bound + + target_area = random.random() * total_area + accumulated_area = 0.0 + + for (x1, y1), (x2, y2) in zip(points, points[1:]): + width = x2 - x1 + + if width <= 0.0: + continue + + segment_area = width * (y1 + y2) / 2.0 + + if accumulated_area + segment_area >= target_area: + target = target_area - accumulated_area + slope = (y2 - y1) / width + + if abs(slope) < 1e-12: + # Constant-density segment. + dx = target / y1 if y1 > 0.0 else 0.0 + else: + # Solve: + # + # y1 * dx + 0.5 * slope * dx² = target + # + discriminant = y1 * y1 + 2.0 * slope * target + dx = (-y1 + math.sqrt(max(0.0, discriminant))) / slope + + dx = max(0.0, min(width, dx)) + normalized_value = x1 + dx + + return ( + lower_bound + + normalized_value * (upper_bound - lower_bound) + ) + + accumulated_area += segment_area + + return upper_bound + + # Return a new callable function instance. + return sample From e8f1b2b5c3700dbde99c3993463ecdccb1260990 Mon Sep 17 00:00:00 2001 From: Noah-Jaffe Date: Sun, 13 Sep 2026 13:40:12 -0400 Subject: [PATCH 16/16] update distribution to do continuous mode and remove reliance on discrete steps --- src/windows/others/distribution.py | 65 +++++------------------------- 1 file changed, 11 insertions(+), 54 deletions(-) diff --git a/src/windows/others/distribution.py b/src/windows/others/distribution.py index ba4ff31..ba60744 100644 --- a/src/windows/others/distribution.py +++ b/src/windows/others/distribution.py @@ -1,7 +1,7 @@ import math import tkinter as tk from tkinter import BOTTOM, LEFT, RIGHT, TOP, Canvas -from tkinter.ttk import Button, Combobox, Frame, Label, Spinbox +from tkinter.ttk import Button, Combobox, Frame, Label from windows.popup import Popup @@ -23,9 +23,6 @@ class DistributionDrawer(Popup): CANVAS_WIDTH = 600 CANVAS_HEIGHT = 300 - DEFAULT_SAMPLE_COUNT = 128 - MIN_SAMPLE_COUNT = 1 - MAX_SAMPLE_COUNT = 4096 EDIT_RADIUS_X = 0.012 ERASE_RADIUS_X = 0.018 @@ -130,24 +127,6 @@ def __init__(self, parent, main_app, lower_bound, upper_bound, distribution=None ) self.preset_description.pack(anchor="w", pady=(0, 12)) - Label(control_frame, text="Number of samples / steps").pack(anchor="w") - self.steps_var = tk.StringVar(value=str(self.DEFAULT_SAMPLE_COUNT)) - self.steps_spinbox = Spinbox( - control_frame, - from_=self.MIN_SAMPLE_COUNT, - to=self.MAX_SAMPLE_COUNT, - increment=1, - textvariable=self.steps_var, - width=10, - validate="key", - validatecommand=(self.register(self._validate_steps), "%P"), - ) - self.steps_spinbox.pack(anchor="w", pady=(2, 2)) - self._add_tooltip( - self.steps_spinbox, - "Controls how many evenly spaced points are saved when you apply the curve (1–4096).", - ) - Button( control_frame, text="Interpolate missing values", @@ -192,21 +171,6 @@ def __init__(self, parent, main_app, lower_bound, upper_bound, distribution=None self.geometry(f"{popup_width}x{popup_height}") self.wait_window() - def _validate_steps(self, value): - if value == "": - return True - try: - return self.MIN_SAMPLE_COUNT <= int(value) <= self.MAX_SAMPLE_COUNT - except ValueError: - return False - - def _get_sample_count(self): - try: - value = int(self.steps_var.get()) - except ValueError: - value = self.DEFAULT_SAMPLE_COUNT - return max(self.MIN_SAMPLE_COUNT, min(self.MAX_SAMPLE_COUNT, value)) - def _add_tooltip(self, widget, text): tooltip = {"window": None} @@ -458,14 +422,13 @@ def apply_selected_preset(self, _event=None): self.preset_description.config(text=self._preset_description(name)) if name == "Freehand": return - self.points = self._make_preset(self.PRESETS[name], self._get_sample_count()) + self.points = self._make_preset(self.PRESETS[name]) self._user_points = self.points.copy() self._stroke = [] self.redraw() - def _make_preset(self, preset, sample_count): - if sample_count <= 1: - sample_count = 2 + def _make_preset(self, preset): + sample_count = 256 raw = [] for index in range(sample_count): x = index / (sample_count - 1) @@ -503,16 +466,16 @@ def _ex_gaussian_pdf(x, mu, sigma, rate): def interpolate_missing(self): """ - Make the current drawing continuous at the selected number of steps. + Make the current drawing continuous. The interpolated values replace the visible drawing immediately. """ if len(self.points) < 2: return - sample_count = self._get_sample_count() + sample_count = max(2, min(256, len(self.points) * 2)) result = [] for index in range(sample_count): - x = index / (sample_count - 1) if sample_count > 1 else 0.0 + x = index / (sample_count - 1) y = self._interpolate(self.points, x) result.append((x, max(0.0, min(1.0, y)))) # Preserve the existing user-selected points so they remain green. @@ -535,16 +498,10 @@ def _resample_distribution(self): if len(points) < 2: return None - sample_count = self._get_sample_count() - result = [] - for index in range(sample_count): - x = index / (sample_count - 1) if sample_count > 1 else 0.0 - y = self._interpolate(points, x) - result.append([round(x, 6), round(max(0.0, min(1.0, y)), 6)]) - - if max(point[1] for point in result) <= 0: - return None - return result + return [ + [round(x, 6), round(max(0.0, min(1.0, y)), 6)] + for x, y in points + ] @staticmethod def _interpolate(points, x):