Initial commit, reusable code for my nvgt based games.
This commit is contained in:
182
README.md
Normal file
182
README.md
Normal file
@@ -0,0 +1,182 @@
|
||||
# libstorm-nvgt
|
||||
|
||||
Reusable NVGT helpers for Storm projects.
|
||||
|
||||
## Modules
|
||||
|
||||
- `multikey.nvgt`: Helper functions for handling multiple equivalent keys.
|
||||
- `file_viewer.nvgt`: Accessible text/file viewer and optional editor.
|
||||
- `text_reader_compat.nvgt`: Optional aliases (`text_reader*`) for legacy code.
|
||||
- `ui.nvgt`: Dialog wrappers with optional main-window restoration.
|
||||
- `speech_history.nvgt`: `speak_with_history()` wrapper plus comma/period history navigation.
|
||||
- `notifications.nvgt`: Queued notifications with history and optional sound playback.
|
||||
- `menu_helpers.nvgt`: Simple menu runner + filter helpers with `sounds/menu` defaults.
|
||||
- `audio_paths.nvgt`: Shared `.ogg` / `.wav` audio path resolution helper.
|
||||
- `learn_sounds.nvgt`: Reusable learn-sounds browser with project-level exclusion/description hooks.
|
||||
- `docs_browser.nvgt`: Reusable document discovery/opening helpers for menus.
|
||||
- `dict_utils.nvgt`: Typed dictionary access helpers.
|
||||
- `save_utils.nvgt`: Generic save file I/O and AES helpers.
|
||||
- `name_sanitize.nvgt`: Name normalization and safe filename builders.
|
||||
- `crash_logger.nvgt`: Timestamped logging and unhandled exception logging helpers.
|
||||
- `volume_controls.nvgt`: Reusable global volume key handling and sound handle cleanup.
|
||||
|
||||
## Usage
|
||||
|
||||
```nvgt
|
||||
#include "libstorm-nvgt/multikey.nvgt"
|
||||
#include "libstorm-nvgt/file_viewer.nvgt"
|
||||
#include "libstorm-nvgt/text_reader_compat.nvgt" // Optional legacy aliases
|
||||
#include "libstorm-nvgt/ui.nvgt"
|
||||
#include "libstorm-nvgt/speech_history.nvgt"
|
||||
#include "libstorm-nvgt/notifications.nvgt"
|
||||
#include "libstorm-nvgt/menu_helpers.nvgt"
|
||||
#include "libstorm-nvgt/learn_sounds.nvgt"
|
||||
#include "libstorm-nvgt/docs_browser.nvgt"
|
||||
#include "libstorm-nvgt/dict_utils.nvgt"
|
||||
#include "libstorm-nvgt/save_utils.nvgt"
|
||||
#include "libstorm-nvgt/name_sanitize.nvgt"
|
||||
#include "libstorm-nvgt/crash_logger.nvgt"
|
||||
#include "libstorm-nvgt/volume_controls.nvgt"
|
||||
```
|
||||
|
||||
## multikey Example
|
||||
|
||||
```nvgt
|
||||
array<int> jumpKeys = {KEY_UP, KEY_W, KEY_SPACE};
|
||||
array<int> runKeys = {KEY_LCTRL, KEY_RCTRL};
|
||||
|
||||
if (check_key_down(runKeys)) {
|
||||
moveInterval = 100;
|
||||
}
|
||||
|
||||
if (check_key_down(jumpKeys) && !isJumping) {
|
||||
isJumping = true;
|
||||
}
|
||||
```
|
||||
|
||||
## ui Example
|
||||
|
||||
```nvgt
|
||||
ui_set_default_window_title("My Game");
|
||||
string playerName = ui_input_box("New Game", "Enter your name", "");
|
||||
if (ui_question("Confirm", "Start with this name?") == 1) {
|
||||
ui_info_box("New Game", "Ready", "Starting game now.");
|
||||
}
|
||||
```
|
||||
|
||||
## menu_helpers Examples
|
||||
|
||||
```nvgt
|
||||
string[] options = {"New Game", "Load Game", "Exit"};
|
||||
int selected = menu_run_simple("Main menu.", options, true, 0, "sounds/menu");
|
||||
if (selected == 2 || selected == -1) {
|
||||
exit();
|
||||
}
|
||||
```
|
||||
|
||||
```nvgt
|
||||
// Prefix filtering helper usage in custom loops.
|
||||
string filterText = "";
|
||||
int[] filteredIndices;
|
||||
string[] filteredOptions;
|
||||
int selection = 0;
|
||||
|
||||
menu_apply_prefix_filter(filterText, options, filteredIndices, filteredOptions);
|
||||
bool changed = menu_update_prefix_filter(filterText, options, filteredIndices, filteredOptions, selection);
|
||||
```
|
||||
|
||||
## file_viewer Example
|
||||
|
||||
```nvgt
|
||||
file_viewer_file("README.txt", "Readme", true);
|
||||
```
|
||||
|
||||
## speech_history + notifications Example
|
||||
|
||||
```nvgt
|
||||
#include "libstorm-nvgt/speech_history.nvgt"
|
||||
#include "libstorm-nvgt/notifications.nvgt"
|
||||
|
||||
void speak_notification_with_history(const string &in message, bool interrupt) {
|
||||
speak_with_history(message, interrupt);
|
||||
}
|
||||
|
||||
void boot() {
|
||||
notifications_set_sound_path("sounds/notify");
|
||||
notifications_set_speak_callback(speak_notification_with_history);
|
||||
speak_with_history("Game loaded.", true);
|
||||
}
|
||||
|
||||
void game_loop_tick() {
|
||||
notifications_update();
|
||||
notifications_check_keys();
|
||||
check_speech_history_keys();
|
||||
}
|
||||
|
||||
void on_event() {
|
||||
notifications_enqueue("A resident has returned.");
|
||||
}
|
||||
```
|
||||
|
||||
## learn_sounds Example (Project Override File)
|
||||
|
||||
```nvgt
|
||||
// In your project root, create excluded_sounds.nvgt:
|
||||
void configure_project_learn_sounds() {
|
||||
learn_sounds_reset_configuration();
|
||||
learn_sounds_add_skip_entry("sounds/menu/");
|
||||
learn_sounds_add_description("sounds/actions/falling.ogg", "Lowers in pitch as the fall progresses.");
|
||||
}
|
||||
```
|
||||
|
||||
```nvgt
|
||||
// In your game module:
|
||||
#include "libstorm-nvgt/learn_sounds.nvgt"
|
||||
#include "excluded_sounds.nvgt"
|
||||
#include "libstorm-nvgt/speech_history.nvgt"
|
||||
|
||||
void learn_sounds_bridge_speak(const string &in message, bool interrupt) {
|
||||
speak_with_history(message, interrupt);
|
||||
}
|
||||
|
||||
void configure_and_run_learn_sounds() {
|
||||
learn_sounds_set_speak_callback(learn_sounds_bridge_speak);
|
||||
learn_sounds_set_menu_sound_dir("sounds/menu");
|
||||
learn_sounds_set_root_dir("sounds");
|
||||
configure_project_learn_sounds();
|
||||
learn_sounds_run_menu();
|
||||
}
|
||||
```
|
||||
|
||||
## save_utils + name_sanitize Example
|
||||
|
||||
```nvgt
|
||||
string filename = build_filename_from_name("Thora", ".dat", 40, "character");
|
||||
save_encrypted_file(filename, serialize(saveData), "my_game_save_key");
|
||||
```
|
||||
|
||||
## dict_utils + crash_logger Example
|
||||
|
||||
```nvgt
|
||||
double dayNumber = dict_get_number(saveData, "time_current_day", 1);
|
||||
if (has_exception()) {
|
||||
log_unhandled_exception_to_file("crash.log", "load_game");
|
||||
}
|
||||
```
|
||||
|
||||
## volume_controls Example
|
||||
|
||||
```nvgt
|
||||
void on_volume_applied(float volumeDb) {
|
||||
sound_master_volume = volumeDb;
|
||||
}
|
||||
|
||||
void init_audio() {
|
||||
volume_controls_set_apply_callback(on_volume_applied);
|
||||
volume_controls_configure(-60.0f, 0.0f, 3.0f, 0.0f);
|
||||
}
|
||||
|
||||
void tick() {
|
||||
volume_controls_handle_keys(KEY_PAGEDOWN, KEY_PAGEUP, true);
|
||||
}
|
||||
```
|
||||
25
audio_paths.nvgt
Normal file
25
audio_paths.nvgt
Normal file
@@ -0,0 +1,25 @@
|
||||
// Audio path helpers for reusable modules.
|
||||
|
||||
// Resolves an audio path with optional extension fallback.
|
||||
// Checks exact path first, then ".ogg", then ".wav".
|
||||
string resolve_audio_path(const string audioPath) {
|
||||
if (audioPath == "") {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (file_exists(audioPath)) {
|
||||
return audioPath;
|
||||
}
|
||||
|
||||
string oggPath = audioPath + ".ogg";
|
||||
if (file_exists(oggPath)) {
|
||||
return oggPath;
|
||||
}
|
||||
|
||||
string wavPath = audioPath + ".wav";
|
||||
if (file_exists(wavPath)) {
|
||||
return wavPath;
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
58
crash_logger.nvgt
Normal file
58
crash_logger.nvgt
Normal file
@@ -0,0 +1,58 @@
|
||||
// Crash and log helpers.
|
||||
|
||||
string log_format_timestamp() {
|
||||
datetime dt;
|
||||
string stamp = dt.format(DATE_TIME_FORMAT_RFC1123);
|
||||
return "[" + stamp + "]";
|
||||
}
|
||||
|
||||
string log_flatten_multiline(const string&in text) {
|
||||
string result = "";
|
||||
bool lastWasSeparator = false;
|
||||
|
||||
for (uint charIndex = 0; charIndex < text.length(); charIndex++) {
|
||||
string ch = text.substr(charIndex, 1);
|
||||
if (ch == "\r" || ch == "\n") {
|
||||
if (!lastWasSeparator) {
|
||||
result += " | ";
|
||||
lastWasSeparator = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
result += ch;
|
||||
lastWasSeparator = false;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
bool log_append_line(const string&in logPath, const string&in message) {
|
||||
file logFile;
|
||||
if (!logFile.open(logPath, "ab")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Keep log format as message then timestamp.
|
||||
logFile.write(message + " " + log_format_timestamp() + "\r\n");
|
||||
logFile.close();
|
||||
return true;
|
||||
}
|
||||
|
||||
void log_unhandled_exception_to_file(const string&in logPath = "crash.log", const string&in context = "") {
|
||||
string info = get_exception_info();
|
||||
string filePath = get_exception_file();
|
||||
int line = get_exception_line();
|
||||
string func = get_exception_function();
|
||||
string stack = log_flatten_multiline(last_exception_call_stack);
|
||||
|
||||
string message = "Unhandled exception";
|
||||
if (context != "") message += " (" + context + ")";
|
||||
if (info != "") message += ": " + info;
|
||||
if (filePath != "") message += " at " + filePath;
|
||||
if (line > 0) message += ":" + line;
|
||||
if (func != "") message += " in " + func;
|
||||
if (stack != "") message += " | stack: " + stack;
|
||||
|
||||
log_append_line(logPath, message);
|
||||
}
|
||||
58
dict_utils.nvgt
Normal file
58
dict_utils.nvgt
Normal file
@@ -0,0 +1,58 @@
|
||||
// Dictionary utility helpers.
|
||||
|
||||
double dict_get_number(dictionary@ data, const string&in key, double defaultValue) {
|
||||
double value = 0.0;
|
||||
if (@data is null) return defaultValue;
|
||||
if (data.get(key, value)) return value;
|
||||
|
||||
int valueInt = 0;
|
||||
if (data.get(key, valueInt)) return valueInt;
|
||||
|
||||
string valueString = "";
|
||||
if (data.get(key, valueString)) {
|
||||
return parse_int(valueString);
|
||||
}
|
||||
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
bool dict_get_bool(dictionary@ data, const string&in key, bool defaultValue) {
|
||||
bool value = false;
|
||||
if (@data is null) return defaultValue;
|
||||
if (data.get(key, value)) return value;
|
||||
|
||||
int valueInt = 0;
|
||||
if (data.get(key, valueInt)) return valueInt != 0;
|
||||
|
||||
string valueString = "";
|
||||
if (data.get(key, valueString)) return valueString == "1" || valueString == "true";
|
||||
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
bool dict_has_keys(dictionary@ data) {
|
||||
if (@data is null) return false;
|
||||
string[]@ keys = data.get_keys();
|
||||
return @keys !is null && keys.length() > 0;
|
||||
}
|
||||
|
||||
bool dict_has_number_key(dictionary@ data, const string&in key) {
|
||||
double value = 0.0;
|
||||
if (@data is null) return false;
|
||||
if (data.get(key, value)) return true;
|
||||
|
||||
int valueInt = 0;
|
||||
if (data.get(key, valueInt)) return true;
|
||||
|
||||
string valueString = "";
|
||||
if (data.get(key, valueString)) return valueString.length() > 0;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
string[] dict_get_string_list(dictionary@ data, const string&in key) {
|
||||
string[] result;
|
||||
if (@data is null) return result;
|
||||
if (!data.get(key, result)) return result;
|
||||
return result;
|
||||
}
|
||||
204
docs_browser.nvgt
Normal file
204
docs_browser.nvgt
Normal file
@@ -0,0 +1,204 @@
|
||||
#include "audio_paths.nvgt"
|
||||
#include "file_viewer.nvgt"
|
||||
|
||||
funcdef void docs_browser_speak_callback(const string &in message, bool interrupt);
|
||||
funcdef void docs_browser_tick_callback();
|
||||
|
||||
docs_browser_speak_callback@ docsBrowserSpeakCallback = null;
|
||||
docs_browser_tick_callback@ docsBrowserTickCallback = null;
|
||||
|
||||
string docsBrowserDirectory = "files";
|
||||
string docsBrowserMenuSoundDir = "sounds/menu";
|
||||
bool docsBrowserWrap = true;
|
||||
|
||||
string[] docsBrowserExtensions = {"md", "MD", "txt", "TXT"};
|
||||
|
||||
sound docsBrowserMoveSound;
|
||||
sound docsBrowserSelectSound;
|
||||
|
||||
void docs_browser_set_speak_callback(docs_browser_speak_callback@ callback) {
|
||||
@docsBrowserSpeakCallback = @callback;
|
||||
}
|
||||
|
||||
void docs_browser_set_tick_callback(docs_browser_tick_callback@ callback) {
|
||||
@docsBrowserTickCallback = @callback;
|
||||
}
|
||||
|
||||
void docs_browser_set_docs_dir(const string docsDir) {
|
||||
docsBrowserDirectory = docsDir;
|
||||
}
|
||||
|
||||
void docs_browser_set_menu_sound_dir(const string menuSoundDir) {
|
||||
docsBrowserMenuSoundDir = menuSoundDir;
|
||||
}
|
||||
|
||||
void docs_browser_set_wrap(bool wrap) {
|
||||
docsBrowserWrap = wrap;
|
||||
}
|
||||
|
||||
void docs_browser_set_extensions(string[]@ extensions) {
|
||||
docsBrowserExtensions.resize(0);
|
||||
if (@extensions is null) return;
|
||||
for (uint extIndex = 0; extIndex < extensions.length(); extIndex++) {
|
||||
docsBrowserExtensions.insert_last(extensions[extIndex]);
|
||||
}
|
||||
}
|
||||
|
||||
void docs_browser_reset_default_extensions() {
|
||||
string[] defaults = {"md", "MD", "txt", "TXT"};
|
||||
docs_browser_set_extensions(defaults);
|
||||
}
|
||||
|
||||
void docs_browser_speak(const string &in message, bool interrupt) {
|
||||
if (@docsBrowserSpeakCallback !is null) {
|
||||
docsBrowserSpeakCallback(message, interrupt);
|
||||
return;
|
||||
}
|
||||
screen_reader_speak(message, interrupt);
|
||||
}
|
||||
|
||||
void docs_browser_tick() {
|
||||
if (@docsBrowserTickCallback !is null) {
|
||||
docsBrowserTickCallback();
|
||||
}
|
||||
}
|
||||
|
||||
bool docs_browser_sort_case_insensitive(const string &in a, const string &in b) {
|
||||
return a.lower() < b.lower();
|
||||
}
|
||||
|
||||
void docs_browser_append_unique_case_insensitive(string[]@ items, const string&in value) {
|
||||
string lowerValue = value.lower();
|
||||
for (uint itemIndex = 0; itemIndex < items.length(); itemIndex++) {
|
||||
if (items[itemIndex].lower() == lowerValue) return;
|
||||
}
|
||||
items.insert_last(value);
|
||||
}
|
||||
|
||||
string docs_browser_collapse_spaces(const string&in text) {
|
||||
string result = text;
|
||||
while (result.find_first(" ") > -1) {
|
||||
result = result.replace(" ", " ", true);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
string docs_browser_format_label(const string&in filename) {
|
||||
string name = filename;
|
||||
int dotPos = name.find_last_of(".");
|
||||
if (dotPos > 0) {
|
||||
name = name.substr(0, dotPos);
|
||||
}
|
||||
|
||||
name = name.replace("_", " ", true);
|
||||
name = name.replace("-", " ", true);
|
||||
name = docs_browser_collapse_spaces(name);
|
||||
name = name.lower();
|
||||
name.trim_whitespace_this();
|
||||
|
||||
if (name.length() == 0) return "Document";
|
||||
string first = name.substr(0, 1).upper();
|
||||
if (name.length() == 1) return first;
|
||||
return first + name.substr(1);
|
||||
}
|
||||
|
||||
void docs_browser_collect_entries(string[]@ labels, string[]@ paths) {
|
||||
labels.resize(0);
|
||||
paths.resize(0);
|
||||
|
||||
if (!directory_exists(docsBrowserDirectory)) return;
|
||||
|
||||
string[] docFiles;
|
||||
for (uint extIndex = 0; extIndex < docsBrowserExtensions.length(); extIndex++) {
|
||||
string ext = docsBrowserExtensions[extIndex];
|
||||
if (ext == "") continue;
|
||||
if (ext.substr(0, 1) == ".") {
|
||||
ext = ext.substr(1);
|
||||
}
|
||||
|
||||
string[]@ found = find_files(docsBrowserDirectory + "/*." + ext);
|
||||
if (@found is null) continue;
|
||||
for (uint fileIndex = 0; fileIndex < found.length(); fileIndex++) {
|
||||
docs_browser_append_unique_case_insensitive(docFiles, found[fileIndex]);
|
||||
}
|
||||
}
|
||||
|
||||
if (docFiles.length() > 1) {
|
||||
docFiles.sort(docs_browser_sort_case_insensitive);
|
||||
}
|
||||
|
||||
for (uint fileIndex = 0; fileIndex < docFiles.length(); fileIndex++) {
|
||||
labels.insert_last(docs_browser_format_label(docFiles[fileIndex]));
|
||||
paths.insert_last(docsBrowserDirectory + "/" + docFiles[fileIndex]);
|
||||
}
|
||||
}
|
||||
|
||||
void docs_browser_add_entries(string[]@ labels, string[]@ paths, int[]@ types, const int typeValue = 1) {
|
||||
string[] docLabels;
|
||||
string[] docPaths;
|
||||
docs_browser_collect_entries(docLabels, docPaths);
|
||||
|
||||
for (uint entryIndex = 0; entryIndex < docLabels.length(); entryIndex++) {
|
||||
labels.insert_last(docLabels[entryIndex]);
|
||||
paths.insert_last(docPaths[entryIndex]);
|
||||
types.insert_last(typeValue);
|
||||
}
|
||||
}
|
||||
|
||||
void docs_browser_play_ui_sound(sound &inout soundObj, const string basePath) {
|
||||
string soundPath = resolve_audio_path(basePath);
|
||||
if (soundPath == "") return;
|
||||
|
||||
soundObj.close();
|
||||
if (!soundObj.load(soundPath)) return;
|
||||
soundObj.play();
|
||||
}
|
||||
|
||||
void docs_browser_run_menu(const string menuTitle = "Documents") {
|
||||
string[] labels;
|
||||
string[] paths;
|
||||
docs_browser_collect_entries(labels, paths);
|
||||
|
||||
docs_browser_speak(menuTitle + ".", true);
|
||||
if (labels.length() == 0) {
|
||||
docs_browser_speak("No documents found.", true);
|
||||
return;
|
||||
}
|
||||
|
||||
int selection = 0;
|
||||
docs_browser_speak(labels[selection], true);
|
||||
|
||||
while (true) {
|
||||
wait(5);
|
||||
docs_browser_tick();
|
||||
|
||||
if (key_pressed(KEY_ESCAPE)) {
|
||||
docs_browser_speak("Closed.", true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key_pressed(KEY_DOWN)) {
|
||||
docs_browser_play_ui_sound(docsBrowserMoveSound, docsBrowserMenuSoundDir + "/menu_move");
|
||||
selection++;
|
||||
if (selection >= int(labels.length())) {
|
||||
selection = docsBrowserWrap ? 0 : int(labels.length()) - 1;
|
||||
}
|
||||
docs_browser_speak(labels[selection], true);
|
||||
}
|
||||
|
||||
if (key_pressed(KEY_UP)) {
|
||||
docs_browser_play_ui_sound(docsBrowserMoveSound, docsBrowserMenuSoundDir + "/menu_move");
|
||||
selection--;
|
||||
if (selection < 0) {
|
||||
selection = docsBrowserWrap ? int(labels.length()) - 1 : 0;
|
||||
}
|
||||
docs_browser_speak(labels[selection], true);
|
||||
}
|
||||
|
||||
if (key_pressed(KEY_RETURN)) {
|
||||
docs_browser_play_ui_sound(docsBrowserSelectSound, docsBrowserMenuSoundDir + "/menu_select");
|
||||
file_viewer_file(paths[selection], labels[selection], true);
|
||||
docs_browser_speak(labels[selection], true);
|
||||
}
|
||||
}
|
||||
}
|
||||
85
file_viewer.nvgt
Normal file
85
file_viewer.nvgt
Normal file
@@ -0,0 +1,85 @@
|
||||
#include "form.nvgt"
|
||||
|
||||
// Reusable text/file viewer based on NVGT audio_form.
|
||||
// Returns edited text in edit mode when user confirms.
|
||||
string file_viewer(string content, string title = "Text Reader", bool readOnly = true) {
|
||||
audio_form f;
|
||||
f.create_window(title, false, true);
|
||||
|
||||
int textControl = f.create_input_box(
|
||||
(readOnly ? "Document (read only)" : "Document (editable)"),
|
||||
content,
|
||||
"",
|
||||
0,
|
||||
readOnly,
|
||||
true,
|
||||
true
|
||||
);
|
||||
|
||||
int okButton = -1;
|
||||
int closeButton = -1;
|
||||
if (readOnly) {
|
||||
closeButton = f.create_button("&Close", true, true);
|
||||
} else {
|
||||
okButton = f.create_button("&OK", true);
|
||||
closeButton = f.create_button("&Cancel", false, true);
|
||||
}
|
||||
|
||||
f.focus(textControl);
|
||||
|
||||
while (true) {
|
||||
f.monitor();
|
||||
wait(5);
|
||||
|
||||
if (!readOnly && okButton != -1 && f.is_pressed(okButton)) {
|
||||
return f.get_text(textControl);
|
||||
}
|
||||
|
||||
if (closeButton != -1 && f.is_pressed(closeButton)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (key_pressed(KEY_ESCAPE)) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
string file_viewer_lines(string[] lines, string title = "Text Reader", bool readOnly = true) {
|
||||
string content = join(lines, "\n");
|
||||
return file_viewer(content, title, readOnly);
|
||||
}
|
||||
|
||||
// Opens a file in the viewer. In edit mode, saves on confirm.
|
||||
// Returns edited text when saved or empty string when canceled/failure.
|
||||
string file_viewer_file(string filePath, string title = "", bool readOnly = true) {
|
||||
file f;
|
||||
if (!f.open(filePath, "rb")) {
|
||||
screen_reader_speak("Failed to open file: " + filePath, true);
|
||||
return "";
|
||||
}
|
||||
|
||||
string content = f.read();
|
||||
f.close();
|
||||
|
||||
if (title == "") {
|
||||
title = filePath;
|
||||
}
|
||||
|
||||
string result = file_viewer(content, title, readOnly);
|
||||
|
||||
if (!readOnly && result != "") {
|
||||
if (f.open(filePath, "wb")) {
|
||||
f.write(result);
|
||||
f.close();
|
||||
screen_reader_speak("File saved successfully", true);
|
||||
return result;
|
||||
}
|
||||
|
||||
screen_reader_speak("Failed to save file", true);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
333
learn_sounds.nvgt
Normal file
333
learn_sounds.nvgt
Normal file
@@ -0,0 +1,333 @@
|
||||
#include "audio_paths.nvgt"
|
||||
|
||||
funcdef void learn_sounds_speak_callback(const string &in message, bool interrupt);
|
||||
funcdef void learn_sounds_tick_callback();
|
||||
funcdef void learn_sounds_setup_callback();
|
||||
|
||||
learn_sounds_speak_callback@ learnSoundsSpeakCallback = null;
|
||||
learn_sounds_tick_callback@ learnSoundsTickCallback = null;
|
||||
learn_sounds_setup_callback@ learnSoundsSetupCallback = null;
|
||||
|
||||
string[] learnSoundsSkipList;
|
||||
string[] learnSoundsDescriptionPaths;
|
||||
string[] learnSoundsDescriptionTexts;
|
||||
|
||||
string learnSoundsRootDir = "sounds";
|
||||
string learnSoundsMenuSoundDir = "sounds/menu";
|
||||
bool learnSoundsWrap = true;
|
||||
bool learnSoundsSetupApplied = false;
|
||||
|
||||
sound learnSoundsPreviewSound;
|
||||
sound learnSoundsMoveSound;
|
||||
sound learnSoundsSelectSound;
|
||||
|
||||
void learn_sounds_set_speak_callback(learn_sounds_speak_callback@ callback) {
|
||||
@learnSoundsSpeakCallback = @callback;
|
||||
}
|
||||
|
||||
void learn_sounds_set_tick_callback(learn_sounds_tick_callback@ callback) {
|
||||
@learnSoundsTickCallback = @callback;
|
||||
}
|
||||
|
||||
void learn_sounds_set_setup_callback(learn_sounds_setup_callback@ callback) {
|
||||
@learnSoundsSetupCallback = @callback;
|
||||
learnSoundsSetupApplied = false;
|
||||
}
|
||||
|
||||
void learn_sounds_set_root_dir(const string rootDir) {
|
||||
learnSoundsRootDir = rootDir;
|
||||
}
|
||||
|
||||
void learn_sounds_set_menu_sound_dir(const string menuSoundDir) {
|
||||
learnSoundsMenuSoundDir = menuSoundDir;
|
||||
}
|
||||
|
||||
void learn_sounds_set_wrap(bool wrap) {
|
||||
learnSoundsWrap = wrap;
|
||||
}
|
||||
|
||||
void learn_sounds_clear_skip_entries() {
|
||||
learnSoundsSkipList.resize(0);
|
||||
}
|
||||
|
||||
void learn_sounds_add_skip_entry(const string entry) {
|
||||
if (entry == "") {
|
||||
return;
|
||||
}
|
||||
learnSoundsSkipList.insert_last(entry);
|
||||
}
|
||||
|
||||
void learn_sounds_clear_descriptions() {
|
||||
learnSoundsDescriptionPaths.resize(0);
|
||||
learnSoundsDescriptionTexts.resize(0);
|
||||
}
|
||||
|
||||
void learn_sounds_add_description(const string soundPath, const string description) {
|
||||
if (soundPath == "") {
|
||||
return;
|
||||
}
|
||||
learnSoundsDescriptionPaths.insert_last(soundPath);
|
||||
learnSoundsDescriptionTexts.insert_last(description);
|
||||
}
|
||||
|
||||
void learn_sounds_reset_configuration() {
|
||||
learn_sounds_clear_skip_entries();
|
||||
learn_sounds_clear_descriptions();
|
||||
learnSoundsSetupApplied = false;
|
||||
}
|
||||
|
||||
void learn_sounds_speak(const string &in message, bool interrupt) {
|
||||
if (@learnSoundsSpeakCallback !is null) {
|
||||
learnSoundsSpeakCallback(message, interrupt);
|
||||
return;
|
||||
}
|
||||
screen_reader_speak(message, interrupt);
|
||||
}
|
||||
|
||||
void learn_sounds_tick() {
|
||||
if (@learnSoundsTickCallback !is null) {
|
||||
learnSoundsTickCallback();
|
||||
}
|
||||
}
|
||||
|
||||
void learn_sounds_apply_setup_once() {
|
||||
if (learnSoundsSetupApplied) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (@learnSoundsSetupCallback !is null) {
|
||||
learnSoundsSetupCallback();
|
||||
}
|
||||
|
||||
learnSoundsSetupApplied = true;
|
||||
}
|
||||
|
||||
string learn_sounds_normalize_path(const string&in path) {
|
||||
return path.replace("\\", "/", true);
|
||||
}
|
||||
|
||||
bool learn_sounds_is_directory_skip_entry(const string&in entry) {
|
||||
if (entry.length() == 0) return false;
|
||||
if (entry.substr(entry.length() - 1) == "/") return true;
|
||||
return directory_exists(entry);
|
||||
}
|
||||
|
||||
bool learn_sounds_should_skip_path(const string&in path) {
|
||||
string normalizedPath = learn_sounds_normalize_path(path);
|
||||
for (uint skipIndex = 0; skipIndex < learnSoundsSkipList.length(); skipIndex++) {
|
||||
string entry = learn_sounds_normalize_path(learnSoundsSkipList[skipIndex]);
|
||||
if (entry.length() == 0) continue;
|
||||
|
||||
bool isDirectory = learn_sounds_is_directory_skip_entry(entry);
|
||||
if (isDirectory) {
|
||||
if (entry.substr(entry.length() - 1) != "/") entry += "/";
|
||||
if (normalizedPath.length() >= entry.length() &&
|
||||
normalizedPath.substr(0, entry.length()) == entry) {
|
||||
return true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (normalizedPath == entry) return true;
|
||||
|
||||
if (entry.find_first("/") < 0 && entry.find_first("\\") < 0) {
|
||||
if (normalizedPath.length() >= entry.length() + 1 &&
|
||||
normalizedPath.substr(normalizedPath.length() - entry.length()) == entry) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
string learn_sounds_get_description(const string&in path) {
|
||||
string normalizedPath = learn_sounds_normalize_path(path);
|
||||
uint descriptionCount = learnSoundsDescriptionPaths.length();
|
||||
if (learnSoundsDescriptionTexts.length() < descriptionCount) {
|
||||
descriptionCount = learnSoundsDescriptionTexts.length();
|
||||
}
|
||||
|
||||
for (uint descriptionIndex = 0; descriptionIndex < descriptionCount; descriptionIndex++) {
|
||||
if (learn_sounds_normalize_path(learnSoundsDescriptionPaths[descriptionIndex]) == normalizedPath) {
|
||||
return learnSoundsDescriptionTexts[descriptionIndex];
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
void learn_sounds_gather_files_recursive(const string&in basePath, string[]@ outFiles) {
|
||||
string[]@ oggFiles = find_files(basePath + "/*.ogg");
|
||||
if (@oggFiles !is null) {
|
||||
for (uint fileIndex = 0; fileIndex < oggFiles.length(); fileIndex++) {
|
||||
outFiles.insert_last(basePath + "/" + oggFiles[fileIndex]);
|
||||
}
|
||||
}
|
||||
|
||||
string[]@ oggUpperFiles = find_files(basePath + "/*.OGG");
|
||||
if (@oggUpperFiles !is null) {
|
||||
for (uint fileIndex = 0; fileIndex < oggUpperFiles.length(); fileIndex++) {
|
||||
outFiles.insert_last(basePath + "/" + oggUpperFiles[fileIndex]);
|
||||
}
|
||||
}
|
||||
|
||||
string[]@ wavFiles = find_files(basePath + "/*.wav");
|
||||
if (@wavFiles !is null) {
|
||||
for (uint fileIndex = 0; fileIndex < wavFiles.length(); fileIndex++) {
|
||||
outFiles.insert_last(basePath + "/" + wavFiles[fileIndex]);
|
||||
}
|
||||
}
|
||||
|
||||
string[]@ wavUpperFiles = find_files(basePath + "/*.WAV");
|
||||
if (@wavUpperFiles !is null) {
|
||||
for (uint fileIndex = 0; fileIndex < wavUpperFiles.length(); fileIndex++) {
|
||||
outFiles.insert_last(basePath + "/" + wavUpperFiles[fileIndex]);
|
||||
}
|
||||
}
|
||||
|
||||
string[]@ folders = find_directories(basePath + "/*");
|
||||
if (@folders !is null) {
|
||||
for (uint folderIndex = 0; folderIndex < folders.length(); folderIndex++) {
|
||||
learn_sounds_gather_files_recursive(basePath + "/" + folders[folderIndex], outFiles);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool learn_sounds_sort_case_insensitive(const string &in a, const string &in b) {
|
||||
return a.lower() < b.lower();
|
||||
}
|
||||
|
||||
string learn_sounds_collapse_spaces(const string&in text) {
|
||||
string result = text;
|
||||
while (result.find_first(" ") > -1) {
|
||||
result = result.replace(" ", " ", true);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
string learn_sounds_label_from_path(const string&in soundPath) {
|
||||
string normalizedPath = learn_sounds_normalize_path(soundPath);
|
||||
int slashPos = normalizedPath.find_last_of("/");
|
||||
string name = (slashPos >= 0) ? normalizedPath.substr(slashPos + 1) : normalizedPath;
|
||||
|
||||
string lowerName = name.lower();
|
||||
if (lowerName.length() > 4 && lowerName.substr(lowerName.length() - 4) == ".ogg") {
|
||||
name = name.substr(0, name.length() - 4);
|
||||
} else if (lowerName.length() > 4 && lowerName.substr(lowerName.length() - 4) == ".wav") {
|
||||
name = name.substr(0, name.length() - 4);
|
||||
}
|
||||
|
||||
name = name.replace("_", " ", true);
|
||||
name = name.replace("-", " ", true);
|
||||
name = learn_sounds_collapse_spaces(name);
|
||||
name = name.lower();
|
||||
name.trim_whitespace_this();
|
||||
|
||||
if (name.length() == 0) return "Sound";
|
||||
return name;
|
||||
}
|
||||
|
||||
void learn_sounds_collect_entries(string[]@ labels, string[]@ soundPaths) {
|
||||
labels.resize(0);
|
||||
soundPaths.resize(0);
|
||||
|
||||
if (!directory_exists(learnSoundsRootDir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
string[] discoveredFiles;
|
||||
learn_sounds_gather_files_recursive(learnSoundsRootDir, discoveredFiles);
|
||||
if (discoveredFiles.length() > 1) {
|
||||
discoveredFiles.sort(learn_sounds_sort_case_insensitive);
|
||||
}
|
||||
|
||||
for (uint fileIndex = 0; fileIndex < discoveredFiles.length(); fileIndex++) {
|
||||
string soundPath = learn_sounds_normalize_path(discoveredFiles[fileIndex]);
|
||||
if (learn_sounds_should_skip_path(soundPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
string label = learn_sounds_label_from_path(soundPath);
|
||||
string description = learn_sounds_get_description(soundPath);
|
||||
if (description.length() > 0) {
|
||||
label += " - " + description;
|
||||
}
|
||||
|
||||
labels.insert_last(label);
|
||||
soundPaths.insert_last(soundPath);
|
||||
}
|
||||
}
|
||||
|
||||
void learn_sounds_play_ui_sound(sound &inout soundObj, const string basePath) {
|
||||
string soundPath = resolve_audio_path(basePath);
|
||||
if (soundPath == "") {
|
||||
return;
|
||||
}
|
||||
|
||||
soundObj.close();
|
||||
if (!soundObj.load(soundPath)) {
|
||||
return;
|
||||
}
|
||||
soundObj.play();
|
||||
}
|
||||
|
||||
void learn_sounds_run_menu() {
|
||||
learn_sounds_apply_setup_once();
|
||||
|
||||
string[] labels;
|
||||
string[] soundPaths;
|
||||
learn_sounds_collect_entries(labels, soundPaths);
|
||||
|
||||
learn_sounds_speak("Learn sounds.", true);
|
||||
if (labels.length() == 0) {
|
||||
learn_sounds_speak("No sounds available.", true);
|
||||
return;
|
||||
}
|
||||
|
||||
int selection = 0;
|
||||
learn_sounds_speak(labels[selection], true);
|
||||
|
||||
while (true) {
|
||||
wait(5);
|
||||
learn_sounds_tick();
|
||||
|
||||
if (key_pressed(KEY_ESCAPE)) {
|
||||
learn_sounds_speak("Closed.", true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key_pressed(KEY_DOWN)) {
|
||||
learn_sounds_play_ui_sound(learnSoundsMoveSound, learnSoundsMenuSoundDir + "/menu_move");
|
||||
selection++;
|
||||
if (selection >= int(labels.length())) {
|
||||
selection = learnSoundsWrap ? 0 : int(labels.length()) - 1;
|
||||
}
|
||||
learn_sounds_speak(labels[selection], true);
|
||||
}
|
||||
|
||||
if (key_pressed(KEY_UP)) {
|
||||
learn_sounds_play_ui_sound(learnSoundsMoveSound, learnSoundsMenuSoundDir + "/menu_move");
|
||||
selection--;
|
||||
if (selection < 0) {
|
||||
selection = learnSoundsWrap ? int(labels.length()) - 1 : 0;
|
||||
}
|
||||
learn_sounds_speak(labels[selection], true);
|
||||
}
|
||||
|
||||
if (key_pressed(KEY_RETURN)) {
|
||||
learn_sounds_play_ui_sound(learnSoundsSelectSound, learnSoundsMenuSoundDir + "/menu_select");
|
||||
string selectedPath = soundPaths[selection];
|
||||
if (!file_exists(selectedPath)) {
|
||||
learn_sounds_speak("Sound not found.", true);
|
||||
continue;
|
||||
}
|
||||
|
||||
learnSoundsPreviewSound.close();
|
||||
if (!learnSoundsPreviewSound.load(selectedPath)) {
|
||||
learn_sounds_speak("Unable to load sound.", true);
|
||||
continue;
|
||||
}
|
||||
learnSoundsPreviewSound.play();
|
||||
}
|
||||
}
|
||||
}
|
||||
122
menu_helpers.nvgt
Normal file
122
menu_helpers.nvgt
Normal file
@@ -0,0 +1,122 @@
|
||||
#include "menu.nvgt"
|
||||
#include "audio_paths.nvgt"
|
||||
|
||||
// Applies common menu sounds from a directory.
|
||||
// Looks for both .ogg and .wav files automatically.
|
||||
void menu_apply_default_sounds(menu@ menuRef, const string soundDir = "sounds/menu") {
|
||||
if (@menuRef is null) {
|
||||
return;
|
||||
}
|
||||
|
||||
menuRef.click_sound = resolve_audio_path(soundDir + "/menu_move");
|
||||
menuRef.select_sound = resolve_audio_path(soundDir + "/menu_select");
|
||||
menuRef.edge_sound = resolve_audio_path(soundDir + "/menu_edge");
|
||||
menuRef.wrap_sound = resolve_audio_path(soundDir + "/menu_wrap");
|
||||
menuRef.open_sound = resolve_audio_path(soundDir + "/menu_open");
|
||||
menuRef.close_sound = resolve_audio_path(soundDir + "/menu_close");
|
||||
}
|
||||
|
||||
// Minimal blocking list menu.
|
||||
// Returns selected index or -1 on empty input/escape.
|
||||
int menu_run_simple(const string introText, string[]@ options, bool wrap = true, int startIndex = 0, const string soundDir = "sounds/menu") {
|
||||
if (@options is null || options.length() == 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
menu menuRef;
|
||||
menu_apply_default_sounds(menuRef, soundDir);
|
||||
menuRef.intro_text = introText;
|
||||
menuRef.wrap = wrap;
|
||||
menuRef.focus_first_item = true;
|
||||
menuRef.add_items(options);
|
||||
|
||||
if (startIndex >= 0 && startIndex < int(options.length())) {
|
||||
menuRef.focused_item = startIndex;
|
||||
}
|
||||
|
||||
return menuRef.run();
|
||||
}
|
||||
|
||||
// Returns a-z for menu prefix filtering, or empty string when no letter key was pressed.
|
||||
string menu_get_filter_letter() {
|
||||
if (key_pressed(KEY_A)) return "a";
|
||||
if (key_pressed(KEY_B)) return "b";
|
||||
if (key_pressed(KEY_C)) return "c";
|
||||
if (key_pressed(KEY_D)) return "d";
|
||||
if (key_pressed(KEY_E)) return "e";
|
||||
if (key_pressed(KEY_F)) return "f";
|
||||
if (key_pressed(KEY_G)) return "g";
|
||||
if (key_pressed(KEY_H)) return "h";
|
||||
if (key_pressed(KEY_I)) return "i";
|
||||
if (key_pressed(KEY_J)) return "j";
|
||||
if (key_pressed(KEY_K)) return "k";
|
||||
if (key_pressed(KEY_L)) return "l";
|
||||
if (key_pressed(KEY_M)) return "m";
|
||||
if (key_pressed(KEY_N)) return "n";
|
||||
if (key_pressed(KEY_O)) return "o";
|
||||
if (key_pressed(KEY_P)) return "p";
|
||||
if (key_pressed(KEY_Q)) return "q";
|
||||
if (key_pressed(KEY_R)) return "r";
|
||||
if (key_pressed(KEY_S)) return "s";
|
||||
if (key_pressed(KEY_T)) return "t";
|
||||
if (key_pressed(KEY_U)) return "u";
|
||||
if (key_pressed(KEY_V)) return "v";
|
||||
if (key_pressed(KEY_W)) return "w";
|
||||
if (key_pressed(KEY_X)) return "x";
|
||||
if (key_pressed(KEY_Y)) return "y";
|
||||
if (key_pressed(KEY_Z)) return "z";
|
||||
return "";
|
||||
}
|
||||
|
||||
// Applies a prefix filter to menu options.
|
||||
void menu_apply_prefix_filter(const string &in filterText, const string[]@ options, int[]@ filteredIndices, string[]@ filteredOptions) {
|
||||
filteredIndices.resize(0);
|
||||
filteredOptions.resize(0);
|
||||
|
||||
if (@options is null) {
|
||||
return;
|
||||
}
|
||||
|
||||
string filterLower = filterText.lower();
|
||||
for (uint optionIndex = 0; optionIndex < options.length(); optionIndex++) {
|
||||
if (filterLower.length() == 0) {
|
||||
filteredIndices.insert_last(optionIndex);
|
||||
filteredOptions.insert_last(options[optionIndex]);
|
||||
continue;
|
||||
}
|
||||
|
||||
string optionLower = options[optionIndex].lower();
|
||||
if (optionLower.length() >= filterLower.length() && optionLower.substr(0, filterLower.length()) == filterLower) {
|
||||
filteredIndices.insert_last(optionIndex);
|
||||
filteredOptions.insert_last(options[optionIndex]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Updates filter text from keyboard input and reapplies filtering.
|
||||
bool menu_update_prefix_filter(string &inout filterText, const string[]@ options, int[]@ filteredIndices, string[]@ filteredOptions, int &inout selection) {
|
||||
bool filterChanged = false;
|
||||
|
||||
if (key_pressed(KEY_BACK) && filterText.length() > 0) {
|
||||
filterText = filterText.substr(0, filterText.length() - 1);
|
||||
filterChanged = true;
|
||||
}
|
||||
|
||||
string filterLetter = menu_get_filter_letter();
|
||||
if (filterLetter != "") {
|
||||
filterText += filterLetter;
|
||||
filterChanged = true;
|
||||
}
|
||||
|
||||
if (filterChanged) {
|
||||
menu_apply_prefix_filter(filterText, options, filteredIndices, filteredOptions);
|
||||
if (selection < 0) {
|
||||
selection = 0;
|
||||
}
|
||||
if (selection >= int(filteredOptions.length())) {
|
||||
selection = 0;
|
||||
}
|
||||
}
|
||||
|
||||
return filterChanged;
|
||||
}
|
||||
30
multikey.nvgt
Normal file
30
multikey.nvgt
Normal file
@@ -0,0 +1,30 @@
|
||||
// Multikey input helpers.
|
||||
bool check_key_down(array<int>@ keys) {
|
||||
// True when at least one key in the set is currently down.
|
||||
if (keys is null || keys.length() == 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (uint keyIndex = 0; keyIndex < keys.length(); keyIndex++) {
|
||||
if (key_down(keys[keyIndex])) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
bool check_all_keys(array<int>@ keys) {
|
||||
// True only when every key in the set is currently down.
|
||||
if (keys is null || keys.length() == 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (uint keyIndex = 0; keyIndex < keys.length(); keyIndex++) {
|
||||
if (!key_down(keys[keyIndex])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
101
name_sanitize.nvgt
Normal file
101
name_sanitize.nvgt
Normal file
@@ -0,0 +1,101 @@
|
||||
// Name and filename sanitizing helpers.
|
||||
|
||||
string normalize_name_whitespace(string name) {
|
||||
string result = "";
|
||||
bool lastWasSpace = true;
|
||||
|
||||
for (uint charIndex = 0; charIndex < name.length(); charIndex++) {
|
||||
string ch = name.substr(charIndex, 1);
|
||||
bool isSpace = (ch == " " || ch == "\t" || ch == "\r" || ch == "\n");
|
||||
if (isSpace) {
|
||||
if (!lastWasSpace) {
|
||||
result += " ";
|
||||
lastWasSpace = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
result += ch;
|
||||
lastWasSpace = false;
|
||||
}
|
||||
|
||||
if (result.length() > 0 && result.substr(result.length() - 1) == " ") {
|
||||
result = result.substr(0, result.length() - 1);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
bool is_windows_reserved_filename(const string&in upperName) {
|
||||
if (upperName == "CON" || upperName == "PRN" || upperName == "AUX" || upperName == "NUL") return true;
|
||||
|
||||
if (upperName.length() == 4 && upperName.substr(0, 3) == "COM") {
|
||||
int num = parse_int(upperName.substr(3));
|
||||
if (num >= 1 && num <= 9) return true;
|
||||
}
|
||||
|
||||
if (upperName.length() == 4 && upperName.substr(0, 3) == "LPT") {
|
||||
int num = parse_int(upperName.substr(3));
|
||||
if (num >= 1 && num <= 9) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
string sanitize_filename_base(string name, const int maxLength = 40, const string fallback = "item") {
|
||||
string normalized = normalize_name_whitespace(name);
|
||||
string result = "";
|
||||
bool lastSeparator = false;
|
||||
|
||||
for (uint charIndex = 0; charIndex < normalized.length(); charIndex++) {
|
||||
string ch = normalized.substr(charIndex, 1);
|
||||
bool isUpper = (ch >= "A" && ch <= "Z");
|
||||
bool isLower = (ch >= "a" && ch <= "z");
|
||||
bool isDigit = (ch >= "0" && ch <= "9");
|
||||
|
||||
if (isUpper || isLower || isDigit) {
|
||||
result += ch;
|
||||
lastSeparator = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch == " " || ch == "_" || ch == "-") {
|
||||
if (!lastSeparator && result.length() > 0) {
|
||||
result += "_";
|
||||
lastSeparator = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
while (result.length() > 0 && result.substr(result.length() - 1) == "_") {
|
||||
result = result.substr(0, result.length() - 1);
|
||||
}
|
||||
|
||||
if (result.length() == 0) {
|
||||
result = fallback;
|
||||
}
|
||||
|
||||
if (result.length() > maxLength) {
|
||||
result = result.substr(0, maxLength);
|
||||
}
|
||||
|
||||
while (result.length() > 0 && result.substr(result.length() - 1) == "_") {
|
||||
result = result.substr(0, result.length() - 1);
|
||||
}
|
||||
|
||||
string upperName = result.upper();
|
||||
if (upperName == "." || upperName == "..") {
|
||||
result = fallback;
|
||||
upperName = result.upper();
|
||||
}
|
||||
|
||||
if (is_windows_reserved_filename(upperName)) {
|
||||
result = "file_" + result;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
string build_filename_from_name(const string&in name, const string&in extension = ".dat", const int maxBaseLength = 40, const string fallback = "item") {
|
||||
return sanitize_filename_base(name, maxBaseLength, fallback) + extension;
|
||||
}
|
||||
175
notifications.nvgt
Normal file
175
notifications.nvgt
Normal file
@@ -0,0 +1,175 @@
|
||||
#include "audio_paths.nvgt"
|
||||
|
||||
funcdef void notification_speak_callback(const string &in message, bool interrupt);
|
||||
|
||||
string[] notificationsHistory;
|
||||
string[] notificationsQueue;
|
||||
|
||||
int notificationsMaxHistory = 10;
|
||||
int notificationsDelayMs = 3000;
|
||||
int notificationsCurrentIndex = -1;
|
||||
bool notificationsActive = false;
|
||||
timer notificationsDelayTimer;
|
||||
sound notificationsSound;
|
||||
bool notificationsWaitingForSound = false;
|
||||
string notificationsSoundPath = "sounds/notify";
|
||||
notification_speak_callback@ notificationsSpeakCallback = null;
|
||||
|
||||
void notifications_set_speak_callback(notification_speak_callback@ callback) {
|
||||
@notificationsSpeakCallback = @callback;
|
||||
}
|
||||
|
||||
void notifications_set_sound_path(const string soundPath) {
|
||||
notificationsSoundPath = soundPath;
|
||||
}
|
||||
|
||||
void notifications_set_delay_ms(int delayMs) {
|
||||
if (delayMs < 0) {
|
||||
delayMs = 0;
|
||||
}
|
||||
notificationsDelayMs = delayMs;
|
||||
}
|
||||
|
||||
void notifications_set_max_history(int maxCount) {
|
||||
if (maxCount < 1) {
|
||||
maxCount = 1;
|
||||
}
|
||||
notificationsMaxHistory = maxCount;
|
||||
|
||||
while (notificationsHistory.length() > uint(notificationsMaxHistory)) {
|
||||
notificationsHistory.remove_at(0);
|
||||
}
|
||||
|
||||
if (notificationsHistory.length() == 0) {
|
||||
notificationsCurrentIndex = -1;
|
||||
} else if (notificationsCurrentIndex >= int(notificationsHistory.length())) {
|
||||
notificationsCurrentIndex = notificationsHistory.length() - 1;
|
||||
}
|
||||
}
|
||||
|
||||
void notifications_clear() {
|
||||
notificationsHistory.resize(0);
|
||||
notificationsQueue.resize(0);
|
||||
notificationsCurrentIndex = -1;
|
||||
notificationsActive = false;
|
||||
notificationsWaitingForSound = false;
|
||||
notificationsSound.close();
|
||||
}
|
||||
|
||||
void notifications_speak(const string &in message, bool interrupt) {
|
||||
if (@notificationsSpeakCallback !is null) {
|
||||
notificationsSpeakCallback(message, interrupt);
|
||||
return;
|
||||
}
|
||||
screen_reader_speak(message, interrupt);
|
||||
}
|
||||
|
||||
void notifications_enqueue(const string &in message) {
|
||||
notificationsQueue.insert_last(message);
|
||||
notificationsHistory.insert_last(message);
|
||||
|
||||
while (notificationsHistory.length() > uint(notificationsMaxHistory)) {
|
||||
notificationsHistory.remove_at(0);
|
||||
}
|
||||
|
||||
notificationsCurrentIndex = notificationsHistory.length() - 1;
|
||||
}
|
||||
|
||||
bool notifications_try_play_sound() {
|
||||
string resolvedPath = resolve_audio_path(notificationsSoundPath);
|
||||
if (resolvedPath == "") {
|
||||
return false;
|
||||
}
|
||||
|
||||
notificationsSound.close();
|
||||
if (!notificationsSound.load(resolvedPath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
notificationsSound.play();
|
||||
return true;
|
||||
}
|
||||
|
||||
void notifications_update() {
|
||||
if (notificationsQueue.length() == 0) {
|
||||
if (notificationsActive && notificationsDelayTimer.elapsed >= notificationsDelayMs) {
|
||||
notificationsActive = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (notificationsActive && notificationsDelayTimer.elapsed < notificationsDelayMs) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (notificationsWaitingForSound) {
|
||||
if (notificationsSound.playing) {
|
||||
return;
|
||||
}
|
||||
|
||||
notifications_speak(notificationsQueue[0], true);
|
||||
notificationsQueue.remove_at(0);
|
||||
notificationsWaitingForSound = false;
|
||||
notificationsActive = true;
|
||||
notificationsDelayTimer.restart();
|
||||
return;
|
||||
}
|
||||
|
||||
if (notifications_try_play_sound()) {
|
||||
notificationsWaitingForSound = true;
|
||||
return;
|
||||
}
|
||||
|
||||
notifications_speak(notificationsQueue[0], true);
|
||||
notificationsQueue.remove_at(0);
|
||||
notificationsActive = true;
|
||||
notificationsDelayTimer.restart();
|
||||
}
|
||||
|
||||
void notifications_check_keys() {
|
||||
if (key_pressed(KEY_LEFTBRACKET)) {
|
||||
if (notificationsHistory.length() == 0) {
|
||||
notifications_speak("No notifications.", true);
|
||||
return;
|
||||
}
|
||||
|
||||
notificationsCurrentIndex--;
|
||||
if (notificationsCurrentIndex < 0) {
|
||||
notificationsCurrentIndex = 0;
|
||||
notifications_speak("Oldest notification. " + notificationsHistory[notificationsCurrentIndex], true);
|
||||
return;
|
||||
}
|
||||
|
||||
int position = notificationsCurrentIndex + 1;
|
||||
notifications_speak(notificationsHistory[notificationsCurrentIndex] + " " + position + " of " + notificationsHistory.length(), true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key_pressed(KEY_RIGHTBRACKET)) {
|
||||
if (notificationsHistory.length() == 0) {
|
||||
notifications_speak("No notifications.", true);
|
||||
return;
|
||||
}
|
||||
|
||||
notificationsCurrentIndex++;
|
||||
if (notificationsCurrentIndex >= int(notificationsHistory.length())) {
|
||||
notificationsCurrentIndex = notificationsHistory.length() - 1;
|
||||
notifications_speak("Newest notification. " + notificationsHistory[notificationsCurrentIndex], true);
|
||||
return;
|
||||
}
|
||||
|
||||
int position = notificationsCurrentIndex + 1;
|
||||
notifications_speak(notificationsHistory[notificationsCurrentIndex] + " " + position + " of " + notificationsHistory.length(), true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key_pressed(KEY_BACKSLASH)) {
|
||||
if (notificationsHistory.length() == 0) {
|
||||
notifications_speak("No notifications.", true);
|
||||
return;
|
||||
}
|
||||
|
||||
notificationsCurrentIndex = notificationsHistory.length() - 1;
|
||||
notifications_speak(notificationsHistory[notificationsCurrentIndex], true);
|
||||
}
|
||||
}
|
||||
101
save_utils.nvgt
Normal file
101
save_utils.nvgt
Normal file
@@ -0,0 +1,101 @@
|
||||
// Generic save/file helpers.
|
||||
|
||||
bool save_utils_sort_string_case_insensitive(const string &in a, const string &in b) {
|
||||
return a.lower() < b.lower();
|
||||
}
|
||||
|
||||
string[] list_files_with_extension(const string&in extension, const string&in directory = "") {
|
||||
string[] result;
|
||||
if (extension == "") return result;
|
||||
|
||||
string dirPrefix = directory;
|
||||
if (dirPrefix != "" && dirPrefix.substr(dirPrefix.length() - 1) != "/") {
|
||||
dirPrefix += "/";
|
||||
}
|
||||
|
||||
string[]@ items = glob(dirPrefix + "*" + extension);
|
||||
if (@items is null) return result;
|
||||
|
||||
for (uint itemIndex = 0; itemIndex < items.length(); itemIndex++) {
|
||||
string item = items[itemIndex];
|
||||
if (item.length() >= extension.length() &&
|
||||
item.substr(item.length() - extension.length()) == extension) {
|
||||
result.insert_last(item);
|
||||
}
|
||||
}
|
||||
|
||||
if (result.length() > 1) {
|
||||
result.sort(save_utils_sort_string_case_insensitive);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
bool has_files_with_extension(const string&in extension, const string&in directory = "") {
|
||||
return list_files_with_extension(extension, directory).length() > 0;
|
||||
}
|
||||
|
||||
string strip_file_extension(const string&in filename, const string&in extension) {
|
||||
if (extension != "" &&
|
||||
filename.length() >= extension.length() &&
|
||||
filename.substr(filename.length() - extension.length()) == extension) {
|
||||
return filename.substr(0, filename.length() - extension.length());
|
||||
}
|
||||
return filename;
|
||||
}
|
||||
|
||||
bool save_string_file(const string&in filename, const string&in data) {
|
||||
if (data.length() == 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
file outFile;
|
||||
if (!outFile.open(filename, "wb")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (outFile.write(data) < data.length()) {
|
||||
outFile.close();
|
||||
return false;
|
||||
}
|
||||
|
||||
outFile.close();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool read_string_file(const string&in filename, string&out data, const bool allowEmpty = false) {
|
||||
file inFile;
|
||||
if (!inFile.open(filename, "rb")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
data = inFile.read();
|
||||
inFile.close();
|
||||
return allowEmpty || data.length() > 0;
|
||||
}
|
||||
|
||||
string encrypt_string_aes(const string&in rawData, const string&in key) {
|
||||
return string_aes_encrypt(rawData, key);
|
||||
}
|
||||
|
||||
string decrypt_string_aes(const string&in encryptedData, const string&in key) {
|
||||
return string_aes_decrypt(encryptedData, key);
|
||||
}
|
||||
|
||||
bool save_encrypted_file(const string&in filename, const string&in rawData, const string&in key) {
|
||||
string encryptedData = encrypt_string_aes(rawData, key);
|
||||
return save_string_file(filename, encryptedData);
|
||||
}
|
||||
|
||||
bool read_encrypted_file(const string&in filename, const string&in key, string&out rawData, const bool allowPlaintextFallback = true) {
|
||||
string encryptedData = "";
|
||||
if (!read_string_file(filename, encryptedData, false)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
rawData = decrypt_string_aes(encryptedData, key);
|
||||
if (rawData.length() == 0 && allowPlaintextFallback) {
|
||||
rawData = encryptedData;
|
||||
}
|
||||
|
||||
return rawData.length() > 0;
|
||||
}
|
||||
106
speech_history.nvgt
Normal file
106
speech_history.nvgt
Normal file
@@ -0,0 +1,106 @@
|
||||
// Speech history helpers.
|
||||
// Provides a reusable speak_with_history wrapper and key navigation.
|
||||
|
||||
string[] speechHistory;
|
||||
int speechHistoryMaxEntries = 10;
|
||||
int speechHistoryCurrentIndex = -1;
|
||||
bool speechHistoryDeduplicate = true;
|
||||
|
||||
void speech_history_set_max_entries(int maxEntries) {
|
||||
if (maxEntries < 1) {
|
||||
maxEntries = 1;
|
||||
}
|
||||
|
||||
speechHistoryMaxEntries = maxEntries;
|
||||
while (speechHistory.length() > uint(speechHistoryMaxEntries)) {
|
||||
speechHistory.remove_at(0);
|
||||
}
|
||||
|
||||
if (speechHistory.length() == 0) {
|
||||
speechHistoryCurrentIndex = -1;
|
||||
} else if (speechHistoryCurrentIndex >= int(speechHistory.length())) {
|
||||
speechHistoryCurrentIndex = speechHistory.length() - 1;
|
||||
}
|
||||
}
|
||||
|
||||
void speech_history_clear() {
|
||||
speechHistory.resize(0);
|
||||
speechHistoryCurrentIndex = -1;
|
||||
}
|
||||
|
||||
void speech_history_set_deduplicate(bool deduplicate) {
|
||||
speechHistoryDeduplicate = deduplicate;
|
||||
}
|
||||
|
||||
void speech_history_add(const string &in message) {
|
||||
if (message == "") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (speechHistoryDeduplicate) {
|
||||
for (uint messageIndex = 0; messageIndex < speechHistory.length(); messageIndex++) {
|
||||
if (speechHistory[messageIndex] == message) {
|
||||
speechHistoryCurrentIndex = messageIndex;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
speechHistory.insert_last(message);
|
||||
while (speechHistory.length() > uint(speechHistoryMaxEntries)) {
|
||||
speechHistory.remove_at(0);
|
||||
}
|
||||
|
||||
speechHistoryCurrentIndex = speechHistory.length() - 1;
|
||||
}
|
||||
|
||||
void speak_with_history(const string &in message, bool interrupt) {
|
||||
speech_history_add(message);
|
||||
screen_reader_speak(message, interrupt);
|
||||
}
|
||||
|
||||
void check_speech_history_keys() {
|
||||
// Comma: older.
|
||||
if (key_pressed(KEY_COMMA)) {
|
||||
if (speechHistory.length() == 0) {
|
||||
screen_reader_speak("No speech history.", true);
|
||||
return;
|
||||
}
|
||||
|
||||
speechHistoryCurrentIndex--;
|
||||
if (speechHistoryCurrentIndex < 0) {
|
||||
speechHistoryCurrentIndex = 0;
|
||||
screen_reader_speak("Oldest message. " + speechHistory[speechHistoryCurrentIndex], true);
|
||||
return;
|
||||
}
|
||||
|
||||
int position = speechHistoryCurrentIndex + 1;
|
||||
screen_reader_speak(speechHistory[speechHistoryCurrentIndex] + " " + position + " of " + speechHistory.length(), true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Period: newer.
|
||||
if (key_pressed(KEY_PERIOD)) {
|
||||
if (speechHistory.length() == 0) {
|
||||
screen_reader_speak("No speech history.", true);
|
||||
return;
|
||||
}
|
||||
|
||||
speechHistoryCurrentIndex++;
|
||||
if (speechHistoryCurrentIndex >= int(speechHistory.length())) {
|
||||
speechHistoryCurrentIndex = speechHistory.length() - 1;
|
||||
screen_reader_speak("Newest message. " + speechHistory[speechHistoryCurrentIndex], true);
|
||||
return;
|
||||
}
|
||||
|
||||
int position = speechHistoryCurrentIndex + 1;
|
||||
screen_reader_speak(speechHistory[speechHistoryCurrentIndex] + " " + position + " of " + speechHistory.length(), true);
|
||||
}
|
||||
}
|
||||
|
||||
string speech_history_latest() {
|
||||
if (speechHistory.length() == 0) {
|
||||
return "";
|
||||
}
|
||||
return speechHistory[speechHistory.length() - 1];
|
||||
}
|
||||
14
text_reader_compat.nvgt
Normal file
14
text_reader_compat.nvgt
Normal file
@@ -0,0 +1,14 @@
|
||||
#include "file_viewer.nvgt"
|
||||
|
||||
// Optional compatibility aliases for projects that still call text_reader*.
|
||||
string text_reader(string content, string title = "Text Reader", bool readOnly = true) {
|
||||
return file_viewer(content, title, readOnly);
|
||||
}
|
||||
|
||||
string text_reader_lines(string[] lines, string title = "Text Reader", bool readOnly = true) {
|
||||
return file_viewer_lines(lines, title, readOnly);
|
||||
}
|
||||
|
||||
string text_reader_file(string filePath, string title = "", bool readOnly = true) {
|
||||
return file_viewer_file(filePath, title, readOnly);
|
||||
}
|
||||
45
ui.nvgt
Normal file
45
ui.nvgt
Normal file
@@ -0,0 +1,45 @@
|
||||
#include "virtual_dialogs.nvgt"
|
||||
|
||||
string uiDefaultWindowTitle = "";
|
||||
|
||||
void ui_set_default_window_title(const string windowTitle) {
|
||||
uiDefaultWindowTitle = windowTitle;
|
||||
}
|
||||
|
||||
void ui_restore_window(const string windowTitle = "") {
|
||||
string resolvedTitle = windowTitle;
|
||||
if (resolvedTitle == "") {
|
||||
resolvedTitle = uiDefaultWindowTitle;
|
||||
}
|
||||
|
||||
if (resolvedTitle != "") {
|
||||
show_window(resolvedTitle);
|
||||
}
|
||||
}
|
||||
|
||||
string ui_input_box(const string title, const string prompt, const string defaultValue = "", const string windowTitle = "") {
|
||||
string dialogTitle = title;
|
||||
if (dialogTitle == "") {
|
||||
dialogTitle = prompt;
|
||||
}
|
||||
|
||||
string result = virtual_input_box(dialogTitle, prompt, defaultValue);
|
||||
ui_restore_window(windowTitle);
|
||||
return result;
|
||||
}
|
||||
|
||||
int ui_question(const string title, const string prompt, const string windowTitle = "", const bool canCancel = false) {
|
||||
string dialogTitle = title;
|
||||
if (dialogTitle == "") {
|
||||
dialogTitle = prompt;
|
||||
}
|
||||
|
||||
int result = virtual_question(dialogTitle, prompt, canCancel);
|
||||
ui_restore_window(windowTitle);
|
||||
return result;
|
||||
}
|
||||
|
||||
void ui_info_box(const string title, const string heading, const string message, const string windowTitle = "") {
|
||||
virtual_info_box(title, heading, message);
|
||||
ui_restore_window(windowTitle);
|
||||
}
|
||||
96
volume_controls.nvgt
Normal file
96
volume_controls.nvgt
Normal file
@@ -0,0 +1,96 @@
|
||||
#include "sound_pool.nvgt"
|
||||
|
||||
funcdef void volume_controls_apply_callback(float volumeDb);
|
||||
|
||||
float volumeControlsMaxDb = 0.0f;
|
||||
float volumeControlsMinDb = -60.0f;
|
||||
float volumeControlsStepDb = 3.0f;
|
||||
float volumeControlsCurrentDb = 0.0f;
|
||||
volume_controls_apply_callback@ volumeControlsApplyCallback = null;
|
||||
|
||||
void volume_controls_set_apply_callback(volume_controls_apply_callback@ callback) {
|
||||
@volumeControlsApplyCallback = @callback;
|
||||
}
|
||||
|
||||
void volume_controls_configure(float minDb = -60.0f, float maxDb = 0.0f, float stepDb = 3.0f, float initialDb = 0.0f) {
|
||||
volumeControlsMinDb = minDb;
|
||||
volumeControlsMaxDb = maxDb;
|
||||
if (volumeControlsMaxDb < volumeControlsMinDb) {
|
||||
float temp = volumeControlsMaxDb;
|
||||
volumeControlsMaxDb = volumeControlsMinDb;
|
||||
volumeControlsMinDb = temp;
|
||||
}
|
||||
|
||||
if (stepDb <= 0.0f) {
|
||||
stepDb = 1.0f;
|
||||
}
|
||||
volumeControlsStepDb = stepDb;
|
||||
|
||||
volume_controls_set_current_db(initialDb, false);
|
||||
}
|
||||
|
||||
int volume_controls_percent_from_db(float volumeDb) {
|
||||
float range = volumeControlsMaxDb - volumeControlsMinDb;
|
||||
if (range <= 0.0f) return 100;
|
||||
|
||||
float normalized = (volumeDb - volumeControlsMinDb) / range;
|
||||
int volumePercent = int(normalized * 100.0f + 0.5f);
|
||||
if (volumePercent < 0) volumePercent = 0;
|
||||
if (volumePercent > 100) volumePercent = 100;
|
||||
return volumePercent;
|
||||
}
|
||||
|
||||
void volume_controls_apply(float volumeDb) {
|
||||
if (@volumeControlsApplyCallback !is null) {
|
||||
volumeControlsApplyCallback(volumeDb);
|
||||
return;
|
||||
}
|
||||
|
||||
// Default engine-global apply behavior when no callback is provided.
|
||||
sound_master_volume = volumeDb;
|
||||
}
|
||||
|
||||
void volume_controls_set_current_db(float volumeDb, bool announce = true) {
|
||||
float clamped = volumeDb;
|
||||
if (clamped > volumeControlsMaxDb) clamped = volumeControlsMaxDb;
|
||||
if (clamped < volumeControlsMinDb) clamped = volumeControlsMinDb;
|
||||
|
||||
if (clamped == volumeControlsCurrentDb) return;
|
||||
|
||||
volumeControlsCurrentDb = clamped;
|
||||
volume_controls_apply(volumeControlsCurrentDb);
|
||||
|
||||
if (announce) {
|
||||
int volumePercent = volume_controls_percent_from_db(volumeControlsCurrentDb);
|
||||
screen_reader_speak("Volume " + volumePercent + ".", true);
|
||||
}
|
||||
}
|
||||
|
||||
void volume_controls_init_at_max(bool announce = false) {
|
||||
volume_controls_set_current_db(volumeControlsMaxDb, announce);
|
||||
}
|
||||
|
||||
float volume_controls_get_current_db() {
|
||||
return volumeControlsCurrentDb;
|
||||
}
|
||||
|
||||
void volume_controls_handle_keys(int downKey = KEY_PAGEDOWN, int upKey = KEY_PAGEUP, bool announce = true) {
|
||||
if (key_pressed(downKey)) {
|
||||
volume_controls_set_current_db(volumeControlsCurrentDb - volumeControlsStepDb, announce);
|
||||
}
|
||||
if (key_pressed(upKey)) {
|
||||
volume_controls_set_current_db(volumeControlsCurrentDb + volumeControlsStepDb, announce);
|
||||
}
|
||||
}
|
||||
|
||||
void safe_destroy_sound_in_pool(sound_pool@ poolRef, int &inout handle) {
|
||||
if (handle == -1) return;
|
||||
if (@poolRef is null) {
|
||||
handle = -1;
|
||||
return;
|
||||
}
|
||||
if (poolRef.sound_is_active(handle)) {
|
||||
poolRef.destroy_sound(handle);
|
||||
}
|
||||
handle = -1;
|
||||
}
|
||||
Reference in New Issue
Block a user