hotkeys_missing_lines writes the shipped default of an action the file does not mention, but that save records the hash of what it wrote, so the file was not reloaded and the action stayed unbound until the file next changed. The bindings installed at startup and on reload now include those defaults (hotkeys_with_missing_defaults), as in Ex4 1.47.2; a file of an older version still gets "none". A logger test covers it.
903 lines
38 KiB
Plaintext
903 lines
38 KiB
Plaintext
// The list of themes the user can pick from, applying one, and remembering the choice.
|
|
|
|
// ---------------------------------------------------------------- paths
|
|
|
|
// Cached on `app` because platform_get_path_program_data loads shell32 on Windows and is slow.
|
|
app_program_data_dir :: () -> string {
|
|
if app.program_data_dir.count return app.program_data_dir;
|
|
|
|
push_allocator(context.default_allocator);
|
|
|
|
program_data_path := platform_get_path_program_data(,,temp);
|
|
dir := sprint("%/%", program_data_path, APP_FOLDER_NAME);
|
|
this_allocation_is_not_a_leak(dir.data);
|
|
|
|
if !make_directory_if_it_does_not_exist(dir, recursive=true) {
|
|
log_error("[theme] Failed to create directory %.", dir);
|
|
}
|
|
|
|
app.program_data_dir = dir;
|
|
return dir;
|
|
}
|
|
|
|
app_themes_dir :: () -> string {
|
|
return tprint("%/themes", app_program_data_dir());
|
|
}
|
|
|
|
// Read-only themes copied out of assets/ by first.jai. Absent in a tree that was never built.
|
|
app_shipped_themes_dir :: () -> string {
|
|
working_directory := app_install_directory();
|
|
return tprint("%1data/themes", working_directory);
|
|
}
|
|
|
|
app_user_settings_path :: () -> string {
|
|
return tprint("%/%", app_program_data_dir(), APP_USER_SETTINGS_FILE_NAME);
|
|
}
|
|
|
|
// ---------------------------------------------------------------- registry
|
|
|
|
app_theme_startup :: () {
|
|
app_theme_refresh_registry();
|
|
|
|
// main loads settings.config before creating the window.
|
|
name := app_user_settings.theme;
|
|
if !name.count name = APP_DEFAULT_THEME_NAME;
|
|
|
|
if !app_theme_apply_name(name) {
|
|
log("[theme] Falling back to the built-in % theme.", APP_DEFAULT_THEME_NAME);
|
|
app_theme_apply_name(APP_DEFAULT_THEME_NAME);
|
|
}
|
|
}
|
|
|
|
APP_DEFAULT_THEME_NAME :: "Default";
|
|
|
|
app_theme_refresh_registry :: () {
|
|
push_allocator(context.default_allocator);
|
|
|
|
// Remember the selection by name; indices move as files appear and disappear.
|
|
selected: string;
|
|
if app_theme.current_index >= 0 && app_theme.current_index < cast(s32) app_theme.entries.count {
|
|
selected = copy_string(app_theme.entries[app_theme.current_index].name,, temp);
|
|
}
|
|
|
|
for * app_theme.entries {
|
|
free(it.name);
|
|
if it.path.count free(it.path);
|
|
}
|
|
array_reset_keeping_memory(*app_theme.entries);
|
|
|
|
array_add(*app_theme.entries, .{copy_string(APP_DEFAULT_THEME_NAME), "", .Builtin, .Default});
|
|
array_add(*app_theme.entries, .{copy_string("Dark"), "", .Builtin, .Dark});
|
|
array_add(*app_theme.entries, .{copy_string("Light"), "", .Builtin, .Light});
|
|
array_add(*app_theme.entries, .{copy_string("Classic"), "", .Builtin, .Classic});
|
|
|
|
builtin_count := app_theme.entries.count;
|
|
|
|
// A user theme of the same name shadows the shipped one, so scan shipped first.
|
|
theme_scan_directory(app_shipped_themes_dir(), create_if_missing = false);
|
|
theme_scan_directory(app_themes_dir(), create_if_missing = true);
|
|
|
|
files := array_view(app_theme.entries, builtin_count, app_theme.entries.count - builtin_count);
|
|
quick_sort(files, (a: Theme_Entry, b: Theme_Entry) -> int { return compare(a.name, b.name); });
|
|
|
|
app_theme.current_index = -1;
|
|
if selected.count {
|
|
for app_theme.entries if it.name == selected { app_theme.current_index = xx it_index; break; }
|
|
}
|
|
}
|
|
|
|
theme_scan_directory :: (dir: string, create_if_missing: bool) {
|
|
// visit_files returns false rather than creating anything, so make the directory first.
|
|
if create_if_missing && !make_directory_if_it_does_not_exist(dir, recursive=true) {
|
|
log_error("[theme] Failed to create %.", dir);
|
|
return;
|
|
}
|
|
if !file_exists(dir) return;
|
|
|
|
visit_files(dir, false, dir, theme_visit_file, visit_files=true, visit_directories=false);
|
|
}
|
|
|
|
theme_visit_file :: (info: *File_Visit_Info, dir: string) {
|
|
if !ends_with(info.short_name, APP_THEME_FILE_EXT) return;
|
|
|
|
stem := slice(info.short_name, 0, info.short_name.count - APP_THEME_FILE_EXT.count);
|
|
if !stem.count return;
|
|
|
|
for * app_theme.entries {
|
|
if it.name != stem continue;
|
|
if it.source == .Builtin {
|
|
log("[theme] Ignoring %: it shadows the built-in % theme.", info.full_name, stem);
|
|
return;
|
|
}
|
|
free(it.path);
|
|
it.path = copy_string(info.full_name); // a user theme replaces the shipped one
|
|
return;
|
|
}
|
|
|
|
array_add(*app_theme.entries, .{copy_string(stem), copy_string(info.full_name), .File, .Default});
|
|
}
|
|
|
|
app_theme_find_index :: (name: string) -> s32 {
|
|
for app_theme.entries if it.name == name return xx it_index;
|
|
return -1;
|
|
}
|
|
|
|
// ---------------------------------------------------------------- applying
|
|
|
|
app_theme_apply_index :: (index: s32) -> bool {
|
|
if index < 0 || index >= cast(s32) app_theme.entries.count {
|
|
log_error("[theme] Theme index % is out of range.", index);
|
|
return false;
|
|
}
|
|
|
|
entry := app_theme.entries[index];
|
|
|
|
if entry.source == .Builtin {
|
|
app_theme_build_builtin(entry.base, *app_theme.master);
|
|
app_theme.palette = app_theme_default_palette();
|
|
// The default accent is too light to stand out on the Light theme's background.
|
|
if entry.base == .Light app_theme.palette.active_window_border = .{0.10, 0.30, 0.75, 1.0};
|
|
} else {
|
|
// Load into scratch so a bad file cannot leave a half-applied theme on screen.
|
|
style: ImGui.Style;
|
|
palette: App_Palette;
|
|
name: string;
|
|
if !app_theme_load_file(entry.path, *style, *palette, *name) return false;
|
|
|
|
app_theme.master = style;
|
|
app_theme.palette = palette;
|
|
}
|
|
|
|
app_theme.ref = app_theme.master;
|
|
app_theme.dirty = false;
|
|
app_theme.current_index = index;
|
|
app_theme_bake(app.win_scale);
|
|
|
|
log("[theme] Applied theme %.", entry.name);
|
|
return true;
|
|
}
|
|
|
|
app_theme_apply_name :: (name: string) -> bool {
|
|
index := app_theme_find_index(name);
|
|
if index < 0 {
|
|
log_error("[theme] No theme named %.", name);
|
|
return false;
|
|
}
|
|
return app_theme_apply_index(index);
|
|
}
|
|
|
|
app_theme_current :: () -> *Theme_Entry {
|
|
if app_theme.current_index < 0 || app_theme.current_index >= cast(s32) app_theme.entries.count return null;
|
|
return *app_theme.entries[app_theme.current_index];
|
|
}
|
|
|
|
// ---------------------------------------------------------------- saving
|
|
|
|
app_theme_save_as :: (name: string) -> bool {
|
|
// Copied because callers pass entries[].name, and refreshing the registry below frees those.
|
|
trimmed := copy_string(trim(name),, temp);
|
|
if !trimmed.count {
|
|
app_theme_set_status("Theme name cannot be empty.", is_error = true);
|
|
return false;
|
|
}
|
|
if app_theme_name_is_builtin(trimmed) {
|
|
app_theme_set_status(tprint("% is a built-in theme name.", trimmed), is_error = true);
|
|
return false;
|
|
}
|
|
if !theme_name_is_valid_file_name(trimmed) {
|
|
app_theme_set_status("A theme name cannot contain / \\ : * ? \" < > | or control characters, end in a dot, or be a reserved name such as CON or NUL.", is_error = true);
|
|
return false;
|
|
}
|
|
|
|
path := tprint("%1/%2%3", app_themes_dir(), trimmed, APP_THEME_FILE_EXT);
|
|
if !app_theme_save_file(path, *app_theme.master, *app_theme.palette, trimmed, .Default) {
|
|
app_theme_set_status(tprint("Could not save %.", path), is_error = true);
|
|
return false;
|
|
}
|
|
|
|
app_theme_refresh_registry();
|
|
|
|
// Re-read what we just wrote: hex quantizes colors to 8 bits per channel, and master must
|
|
// agree with the file or every later comparison against it is off by a fraction.
|
|
if !app_theme_apply_name(trimmed) return false;
|
|
|
|
app_save_user_settings_file();
|
|
app_theme_set_status(tprint("Saved %.", path), is_error = false);
|
|
return true;
|
|
}
|
|
|
|
// The name becomes a file name in the themes folder, so it must be one on every platform.
|
|
theme_name_is_valid_file_name :: (name: string) -> bool {
|
|
if name == "." || name == ".." || name[name.count - 1] == #char "." return false;
|
|
for 0..name.count - 1 {
|
|
c := name[it];
|
|
if c < 32 || find_index_from_left("/\\:*?\"<>|", c) >= 0 return false;
|
|
}
|
|
|
|
// Windows reserves these device names, with or without an extension.
|
|
RESERVED :: string.["CON", "PRN", "AUX", "NUL",
|
|
"COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
|
|
"LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9"];
|
|
stem := name;
|
|
dot := find_index_from_left(name, #char ".");
|
|
if dot >= 0 stem = slice(name, 0, dot);
|
|
for RESERVED if equal_nocase(stem, it) return false;
|
|
return true;
|
|
}
|
|
|
|
app_theme_save_current :: () -> bool {
|
|
entry := app_theme_current();
|
|
if !entry {
|
|
app_theme_set_status("No theme is selected.", is_error = true);
|
|
return false;
|
|
}
|
|
if entry.source == .Builtin {
|
|
app_theme_set_status("Built-in themes cannot be overwritten. Use Save As.", is_error = true);
|
|
return false;
|
|
}
|
|
return app_theme_save_as(entry.name);
|
|
}
|
|
|
|
app_theme_reload_current :: () -> bool {
|
|
entry := app_theme_current();
|
|
if !entry return false;
|
|
name := copy_string(entry.name,, temp);
|
|
if !app_theme_apply_index(app_theme.current_index) {
|
|
toast_show(.Error, "Could not reload theme %; see the Event Log", name);
|
|
return false;
|
|
}
|
|
toast_show(.Success, "Reloaded theme %", name);
|
|
return true;
|
|
}
|
|
|
|
app_theme_open_folder :: () {
|
|
dir := app_themes_dir();
|
|
if !make_directory_if_it_does_not_exist(dir, recursive=true) {
|
|
app_theme_set_status(tprint("Could not create %.", dir), is_error = true);
|
|
return;
|
|
}
|
|
#if USING_WINDOWS {
|
|
dir = copy_string(dir,, temp);
|
|
path_overwrite_separators(dir, #char "\\");
|
|
}
|
|
if !platform_open_directory(dir) app_theme_set_status(tprint("Could not open %.", dir), is_error = true);
|
|
}
|
|
|
|
app_theme_open_current_file :: () {
|
|
entry := app_theme_current();
|
|
if !entry || entry.source == .Builtin || !entry.path.count {
|
|
app_theme_set_status("Built-in themes have no file. Use Save As to make one.", is_error = true);
|
|
return;
|
|
}
|
|
app_open_config_file(entry.path);
|
|
}
|
|
|
|
app_theme_name_is_builtin :: (name: string) -> bool {
|
|
for app_theme.entries if it.source == .Builtin && it.name == name return true;
|
|
return false;
|
|
}
|
|
|
|
app_theme_set_status :: (message: string, is_error: bool) {
|
|
push_allocator(context.default_allocator);
|
|
if app_theme.status.count free(app_theme.status);
|
|
app_theme.status = copy_string(message);
|
|
app_theme.status_is_error = is_error;
|
|
if is_error log_error("[theme] %", message);
|
|
else log("[theme] %", message);
|
|
toast_show(ifx is_error then Toast_Kind.Error else .Success, "%", message);
|
|
}
|
|
|
|
// ---------------------------------------------------------------- user settings
|
|
|
|
app_user_settings: App_User_Settings;
|
|
App_User_Settings :: struct {
|
|
theme: string; // "" means the default theme; heap-allocated
|
|
ocr_model: string; // [ocr] model: the OCR model's name in models.config; "" means the default; heap-allocated
|
|
|
|
// Of the monitor the window first opens on.
|
|
window_width_fraction: float = 0.80;
|
|
window_height_fraction: float = 0.75;
|
|
|
|
// ImGui's style.FontSizeBase and style.FontScaleMain. 0 until read from the file or taken from
|
|
// the live style, and while 0 the startup values ImGui and app_imgui_init set are kept.
|
|
font_size_base: float;
|
|
font_scale_main: float;
|
|
|
|
// Entries the Event Log keeps in memory; the oldest are dropped past it. The log file keeps every line.
|
|
event_log_capacity: s64 = EVENT_LOG_CAPACITY_DEFAULT;
|
|
|
|
// [video]: screen recording (app/screen_record.jai), set by hand. video_capture is "auto", "dxgi"
|
|
// or "wgc"; heap-allocated, "" meaning auto.
|
|
video_fps: s64 = VIDEO_FPS_DEFAULT;
|
|
video_cursor := true;
|
|
video_capture: string;
|
|
video_system_audio := true; // what the computer plays
|
|
video_microphone: bool; // off unless asked for
|
|
|
|
// The [hotkeys] lines. Only meaningful when the section was seen; see hotkeys_install.
|
|
hotkey_bindings: [..] Hotkey_Binding;
|
|
// The [global_hotkeys] lines, likewise; see global_hotkeys_install.
|
|
global_hotkey_bindings: [..] Hotkey_Binding;
|
|
}
|
|
|
|
settings_free :: (s: *App_User_Settings) {
|
|
free(s.theme,, context.default_allocator);
|
|
free(s.ocr_model,, context.default_allocator);
|
|
free(s.video_capture,, context.default_allocator);
|
|
array_free(s.hotkey_bindings);
|
|
array_free(s.global_hotkey_bindings);
|
|
}
|
|
|
|
WINDOW_FRACTION_MIN :: 0.1;
|
|
WINDOW_FRACTION_MAX :: 1.0;
|
|
FONT_SIZE_BASE_MIN :: 8.0; // the Debug Panel slider's range
|
|
FONT_SIZE_BASE_MAX :: 64.0;
|
|
EVENT_LOG_CAPACITY_DEFAULT :: 4096;
|
|
EVENT_LOG_CAPACITY_MIN :: 64;
|
|
EVENT_LOG_CAPACITY_MAX :: 1_000_000;
|
|
VIDEO_FPS_DEFAULT :: 30;
|
|
VIDEO_FPS_MIN :: 1;
|
|
VIDEO_FPS_MAX :: 120;
|
|
|
|
// Fills app_user_settings. Returns true when the file should be written back: it is missing, or
|
|
// is the current version but lacks a key, so every setting ends up listed there to edit by hand.
|
|
// A file of another version is left alone.
|
|
app_load_user_settings_file :: () -> write_back: bool {
|
|
defer settings_note_synced();
|
|
path := app_user_settings_path();
|
|
if !file_exists(path) {
|
|
hotkeys_install(hotkey_defaults());
|
|
global_hotkeys_install(global_hotkey_defaults());
|
|
return true;
|
|
}
|
|
|
|
file_data, read_ok := read_entire_file(path,, temp);
|
|
ok := false;
|
|
seen: Settings_Seen;
|
|
version: s64;
|
|
if read_ok {
|
|
settings_file_hash = Hash.fnv1a_hash(file_data.data, file_data.count);
|
|
ok, seen, version = settings_parse(path, file_data, *app_user_settings);
|
|
}
|
|
if !ok {
|
|
log_error("[settings] Could not read %; using defaults for this session. Fix or remove it, then restart.", path);
|
|
toast_show(.Error, "Could not read %; see the Event Log", APP_USER_SETTINGS_FILE_NAME);
|
|
}
|
|
|
|
bindings: [] Hotkey_Binding = hotkey_defaults();
|
|
if ok && seen.hotkeys bindings = app_user_settings.hotkey_bindings;
|
|
hotkeys_install(hotkeys_with_missing_defaults(hotkey_actions, bindings, hotkey_defaults(), version));
|
|
|
|
global_bindings: [] Hotkey_Binding = global_hotkey_defaults();
|
|
if ok && seen.global_hotkeys global_bindings = app_user_settings.global_hotkey_bindings;
|
|
global_hotkeys_install(hotkeys_with_missing_defaults(global_hotkey_actions, global_bindings, global_hotkey_defaults(), version));
|
|
|
|
if !ok return false;
|
|
|
|
// A new action unmentioned in either section needs writing back (a "none" marker if this file
|
|
// predates the per-action convention, the shipped default otherwise) so it actually reaches the
|
|
// user rather than waiting for them to trigger some unrelated save.
|
|
hotkeys_missing, _ := hotkeys_missing_lines(hotkey_actions, bindings, hotkey_defaults(), version);
|
|
global_missing, _ := hotkeys_missing_lines(global_hotkey_actions, global_bindings, global_hotkey_defaults(), version);
|
|
if hotkeys_missing.count || global_missing.count return true;
|
|
|
|
#if OS == .LINUX seen.global_hotkeys = true; // not written there: Linux has no global hotkeys yet
|
|
return !(seen.theme && seen.width && seen.height && seen.font_size && seen.font_scale && seen.event_log && seen.hotkeys && seen.global_hotkeys && seen.ocr_model && seen.video_fps && seen.video_cursor && seen.video_capture && seen.video_system_audio && seen.video_microphone);
|
|
}
|
|
|
|
Settings_Seen :: struct {
|
|
theme, width, height, font_size, font_scale, event_log, hotkeys, global_hotkeys, ocr_model: bool;
|
|
video_fps, video_cursor, video_capture, video_system_audio, video_microphone: bool;
|
|
}
|
|
|
|
// Of the file's bytes as last loaded or written, so a watcher event for our own save, or for an
|
|
// edit that changed nothing, is not applied again.
|
|
settings_file_hash: u64;
|
|
|
|
// The settings the app itself changes, as it holds them now.
|
|
Settings_App_Values :: struct {
|
|
theme: string;
|
|
ocr_model: string;
|
|
font_size_base: float;
|
|
font_scale_main: float;
|
|
}
|
|
|
|
// The app values as of the last load or save. A save keeps what the file says for a setting that
|
|
// has not changed in the app since, so an edit made by hand while the app runs is not overwritten.
|
|
settings_synced: Settings_App_Values; // theme heap-allocated
|
|
|
|
settings_app_values :: () -> Settings_App_Values {
|
|
values: Settings_App_Values;
|
|
entry := app_theme_current();
|
|
values.theme = ifx entry then entry.name else app_user_settings.theme;
|
|
if !values.theme.count values.theme = APP_DEFAULT_THEME_NAME;
|
|
values.ocr_model = ifx app_user_settings.ocr_model.count then app_user_settings.ocr_model else OCR_DEFAULT_MODEL;
|
|
|
|
values.font_size_base = app_user_settings.font_size_base;
|
|
values.font_scale_main = app_user_settings.font_scale_main;
|
|
if app.imgui_context {
|
|
style := ImGui.GetStyle();
|
|
if style.FontSizeBase > 0 values.font_size_base = style.FontSizeBase;
|
|
values.font_scale_main = style.FontScaleMain;
|
|
}
|
|
if values.font_size_base <= 0 values.font_size_base = default_global_font_size;
|
|
if values.font_scale_main <= 0 values.font_scale_main = 1.0;
|
|
return values;
|
|
}
|
|
|
|
settings_note_synced :: () {
|
|
free(settings_synced.theme,, context.default_allocator);
|
|
free(settings_synced.ocr_model,, context.default_allocator);
|
|
settings_synced = settings_app_values();
|
|
settings_synced.theme = copy_string(settings_synced.theme,, context.default_allocator);
|
|
settings_synced.ocr_model = copy_string(settings_synced.ocr_model,, context.default_allocator);
|
|
}
|
|
|
|
// Parses the file's contents into out, replacing its theme and hotkey bindings. ok is false when
|
|
// the file's version is outside the readable range. version is the file's own @version,
|
|
// meaningful only when ok is true; see APP_USER_SETTINGS_OLDEST_READABLE_VERSION.
|
|
settings_parse :: (path: string, file_data: string, out: *App_User_Settings) -> ok: bool, seen: Settings_Seen, version: s64 {
|
|
seen: Settings_Seen;
|
|
hotkey_actions_init();
|
|
global_hotkey_actions_init();
|
|
|
|
out.hotkey_bindings.allocator = context.default_allocator;
|
|
array_reset(*out.hotkey_bindings);
|
|
out.global_hotkey_bindings.allocator = context.default_allocator;
|
|
array_reset(*out.global_hotkey_bindings);
|
|
|
|
seen_version := false;
|
|
version: s64;
|
|
line_number := 0;
|
|
offset: s64 = 0;
|
|
section: string;
|
|
|
|
line: string;
|
|
while theme_next_line(file_data, *offset, *line) {
|
|
line_number += 1;
|
|
|
|
valid, stripped := strip_comment(line);
|
|
if !valid continue;
|
|
|
|
body := trim(stripped);
|
|
if !body.count continue;
|
|
|
|
if body[0] == #char "@" {
|
|
key, value, split_ok := theme_split_directive(body);
|
|
if split_ok && key == "version" {
|
|
rest := value;
|
|
parsed_version, parsed := parse_int(*rest, s64);
|
|
if !parsed || parsed_version < APP_USER_SETTINGS_OLDEST_READABLE_VERSION || parsed_version > APP_USER_SETTINGS_VERSION {
|
|
log_error("[settings] % is version %, expected % to %. Ignoring it.",
|
|
path, value, APP_USER_SETTINGS_OLDEST_READABLE_VERSION, APP_USER_SETTINGS_VERSION);
|
|
return false, seen, version;
|
|
}
|
|
version = parsed_version;
|
|
seen_version = true;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if settings_is_section_header(body) {
|
|
section = body;
|
|
if section == HOTKEYS_SECTION seen.hotkeys = true;
|
|
if section == GLOBAL_HOTKEYS_SECTION seen.global_hotkeys = true;
|
|
continue;
|
|
}
|
|
if !seen_version continue;
|
|
|
|
if section == HOTKEYS_SECTION {
|
|
binding, binding_ok, error := hotkey_parse_line(body, hotkey_actions);
|
|
if binding_ok array_add(*out.hotkey_bindings, binding);
|
|
else log_error("[settings] %:%: %", path, line_number, error);
|
|
continue;
|
|
}
|
|
if section == GLOBAL_HOTKEYS_SECTION {
|
|
binding, binding_ok, error := hotkey_parse_line(body, global_hotkey_actions);
|
|
if binding_ok array_add(*out.global_hotkey_bindings, binding);
|
|
else log_error("[settings] %:%: %", path, line_number, error);
|
|
continue;
|
|
}
|
|
|
|
key, value, split_ok := theme_split_key(body);
|
|
if !split_ok continue;
|
|
|
|
if section == "[ocr]" && key == "model" {
|
|
free(out.ocr_model,, context.default_allocator);
|
|
out.ocr_model = copy_string(settings_unquote(value),, context.default_allocator);
|
|
seen.ocr_model = true;
|
|
} else if section == "[ui]" && key == "theme" {
|
|
free(out.theme,, context.default_allocator);
|
|
out.theme = copy_string(settings_unquote(value),, context.default_allocator);
|
|
seen.theme = true;
|
|
} else if section == "[window]" && key == "width_fraction" {
|
|
seen.width = settings_read_float(*out.window_width_fraction, path, line_number, key, value, WINDOW_FRACTION_MIN, WINDOW_FRACTION_MAX);
|
|
} else if section == "[window]" && key == "height_fraction" {
|
|
seen.height = settings_read_float(*out.window_height_fraction, path, line_number, key, value, WINDOW_FRACTION_MIN, WINDOW_FRACTION_MAX);
|
|
} else if section == "[font]" && key == "size_base" {
|
|
seen.font_size = settings_read_float(*out.font_size_base, path, line_number, key, value, FONT_SIZE_BASE_MIN, FONT_SIZE_BASE_MAX);
|
|
} else if section == "[font]" && key == "scale_main" {
|
|
seen.font_scale = settings_read_float(*out.font_scale_main, path, line_number, key, value, FONT_SCALE_MIN, FONT_SCALE_MAX);
|
|
} else if section == "[video]" && key == "fps" {
|
|
seen.video_fps = settings_read_int(*out.video_fps, path, line_number, key, value, VIDEO_FPS_MIN, VIDEO_FPS_MAX);
|
|
} else if section == "[video]" && key == "cursor" {
|
|
seen.video_cursor = settings_read_bool(*out.video_cursor, path, line_number, key, value);
|
|
} else if section == "[video]" && key == "system_audio" {
|
|
seen.video_system_audio = settings_read_bool(*out.video_system_audio, path, line_number, key, value);
|
|
} else if section == "[video]" && key == "microphone" {
|
|
seen.video_microphone = settings_read_bool(*out.video_microphone, path, line_number, key, value);
|
|
} else if section == "[video]" && key == "capture" {
|
|
capture := to_lower_copy(settings_unquote(value),, temp);
|
|
if capture == "auto" || capture == "dxgi" || capture == "wgc" {
|
|
free(out.video_capture,, context.default_allocator);
|
|
out.video_capture = copy_string(capture,, context.default_allocator);
|
|
seen.video_capture = true;
|
|
} else {
|
|
log_error("[settings] %:%: capture is %; it takes auto, dxgi or wgc. Using auto.", path, line_number, value);
|
|
}
|
|
} else if section == "[logging]" && key == "event_log_capacity" {
|
|
seen.event_log = settings_read_int(*out.event_log_capacity, path, line_number, key, value, EVENT_LOG_CAPACITY_MIN, EVENT_LOG_CAPACITY_MAX);
|
|
} else {
|
|
log("[settings] %:%: unknown key % in %. Ignored.", path, line_number, key, section);
|
|
}
|
|
}
|
|
|
|
return seen_version, seen, version;
|
|
}
|
|
|
|
// Exactly [name]: a hotkey line binding the [ key also starts with '['.
|
|
settings_is_section_header :: (body: string) -> bool {
|
|
if body.count < 3 || body[0] != #char "[" || body[body.count - 1] != #char "]" return false;
|
|
for 1..body.count - 2 {
|
|
c := body[it];
|
|
if !((c >= #char "a" && c <= #char "z") || (c >= #char "A" && c <= #char "Z") || (c >= #char "0" && c <= #char "9") || c == #char "_") return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// "..." with "" for a literal quote. Anything else is returned as is. The result may point into value.
|
|
settings_unquote :: (value: string) -> string {
|
|
if value.count < 2 || value[0] != #char "\"" || value[value.count - 1] != #char "\"" return value;
|
|
|
|
inner := slice(value, 1, value.count - 2);
|
|
if !contains(inner, "\"\"") return inner;
|
|
return replace(inner, "\"\"", "\"",, temp);
|
|
}
|
|
|
|
// Quoted when the value would not survive a reload bare: a " #" or a leading # reads as a comment,
|
|
// the outer spaces are trimmed, and any quote would open quoting.
|
|
settings_quote_if_needed :: (value: string) -> string {
|
|
needs_quotes := contains(value, " #") || contains(value, "\t#") || contains(value, "\"") ||
|
|
(value.count > 0 && (value[0] == #char "#" || is_space2(value[0]) || is_space2(value[value.count - 1])));
|
|
if !needs_quotes return value;
|
|
return tprint("\"%\"", replace(value, "\"", "\"\"",, temp));
|
|
}
|
|
|
|
settings_read_bool :: (out: *bool, path: string, line_number: int, key: string, value: string) -> bool {
|
|
if equal_nocase(value, "true") { out.* = true; return true; }
|
|
if equal_nocase(value, "false") { out.* = false; return true; }
|
|
log_error("[settings] %:%: % is not true or false. Using the default.", path, line_number, key);
|
|
return false;
|
|
}
|
|
|
|
settings_read_int :: (out: *s64, path: string, line_number: int, key: string, value: string, lo: s64, hi: s64) -> bool {
|
|
v, ok, rest := string_to_int(trim(value));
|
|
if !ok || trim(rest).count {
|
|
log_error("[settings] %:%: % is not a whole number. Using the default.", path, line_number, key);
|
|
return false;
|
|
}
|
|
out.* = clamp(v, lo, hi);
|
|
if out.* != v log("[settings] %:%: % % is outside % to %; using %.", path, line_number, key, v, lo, hi, out.*);
|
|
return true;
|
|
}
|
|
|
|
settings_read_float :: (out: *float, path: string, line_number: int, key: string, value: string, lo: float, hi: float) -> bool {
|
|
v: [1] float;
|
|
if !theme_parse_floats(value, v) {
|
|
log_error("[settings] %:%: % is not a number. Using the default.", path, line_number, key);
|
|
return false;
|
|
}
|
|
out.* = clamp(v[0], lo, hi);
|
|
if out.* != v[0] log("[settings] %:%: % % is outside % to %; using %.", path, line_number, key, v[0], lo, hi, out.*);
|
|
return true;
|
|
}
|
|
|
|
app_apply_font_settings :: () {
|
|
style := ImGui.GetStyle();
|
|
if app_user_settings.font_size_base > 0 {
|
|
style.FontSizeBase = app_user_settings.font_size_base;
|
|
style._NextFrameFontSizeBase = app_user_settings.font_size_base; // #hack as of ImGui v1.92.7, as in the Debug Panel
|
|
}
|
|
if app_user_settings.font_scale_main > 0 style.FontScaleMain = app_user_settings.font_scale_main;
|
|
}
|
|
|
|
// For saves the user asked for. The startup write-back saves quietly.
|
|
app_save_user_settings_file_and_toast :: (what: string) {
|
|
if app_save_user_settings_file() toast_show(.Success, "Saved % to %", what, APP_USER_SETTINGS_FILE_NAME);
|
|
else toast_show(.Error, "Could not save %; see the Event Log", APP_USER_SETTINGS_FILE_NAME);
|
|
}
|
|
|
|
app_save_user_settings_file :: () -> bool {
|
|
// The theme is taken from the registry once it is up; before that, whatever was loaded stands.
|
|
entry := app_theme_current();
|
|
if entry && entry.name != app_user_settings.theme {
|
|
free(app_user_settings.theme,, context.default_allocator);
|
|
app_user_settings.theme = copy_string(entry.name,, context.default_allocator);
|
|
}
|
|
if app.imgui_context {
|
|
style := ImGui.GetStyle();
|
|
if style.FontSizeBase > 0 app_user_settings.font_size_base = style.FontSizeBase;
|
|
app_user_settings.font_scale_main = style.FontScaleMain;
|
|
}
|
|
|
|
values := settings_app_values();
|
|
theme := values.theme;
|
|
ocr_model := values.ocr_model;
|
|
font_size_base := values.font_size_base;
|
|
font_scale_main := values.font_scale_main;
|
|
|
|
// The file may have been edited by hand since it was read: the window size is only ever set
|
|
// there, and the other settings keep the file's value unless changed in the app.
|
|
path := app_user_settings_path();
|
|
on_disk: App_User_Settings;
|
|
defer settings_free(*on_disk);
|
|
disk_ok: bool;
|
|
seen: Settings_Seen;
|
|
disk_version: s64;
|
|
disk_data: string;
|
|
edit_pending: bool; // a hand edit the watcher has not delivered yet
|
|
if file_exists(path) {
|
|
read_ok: bool;
|
|
disk_data, read_ok = read_entire_file(path,, temp);
|
|
if read_ok {
|
|
disk_ok, seen, disk_version = settings_parse(path, disk_data, *on_disk);
|
|
edit_pending = Hash.fnv1a_hash(disk_data.data, disk_data.count) != settings_file_hash;
|
|
}
|
|
if !disk_ok {
|
|
// Refuse rather than fall through to the defaults below: we can't tell what the user wants
|
|
// to keep in a file we can't read or that isn't a version we understand.
|
|
log_error("[settings] % did not parse; refusing to overwrite it. Fix or remove it, then try again.", path);
|
|
return false;
|
|
}
|
|
}
|
|
if disk_ok {
|
|
if seen.theme && values.theme == settings_synced.theme && on_disk.theme.count theme = on_disk.theme;
|
|
if seen.ocr_model && values.ocr_model == settings_synced.ocr_model && on_disk.ocr_model.count ocr_model = on_disk.ocr_model;
|
|
if seen.font_size && values.font_size_base == settings_synced.font_size_base font_size_base = on_disk.font_size_base;
|
|
if seen.font_scale && values.font_scale_main == settings_synced.font_scale_main font_scale_main = on_disk.font_scale_main;
|
|
if seen.width app_user_settings.window_width_fraction = on_disk.window_width_fraction;
|
|
if seen.height app_user_settings.window_height_fraction = on_disk.window_height_fraction;
|
|
if seen.event_log app_user_settings.event_log_capacity = on_disk.event_log_capacity;
|
|
if seen.video_fps app_user_settings.video_fps = on_disk.video_fps;
|
|
if seen.video_cursor app_user_settings.video_cursor = on_disk.video_cursor;
|
|
// video_system_audio and video_microphone are the app's: the recording toolbar's buttons set them.
|
|
if seen.video_capture {
|
|
free(app_user_settings.video_capture,, context.default_allocator);
|
|
app_user_settings.video_capture = copy_string(on_disk.video_capture,, context.default_allocator);
|
|
}
|
|
}
|
|
|
|
builder: String_Builder;
|
|
builder.allocator = temp;
|
|
|
|
print_to_builder(*builder, "# % user settings.\n", APP_NAME);
|
|
print_to_builder(*builder, "@version %\n\n", APP_USER_SETTINGS_VERSION);
|
|
append(*builder, "[ui]\n");
|
|
print_to_builder(*builder, "theme: %\n\n", settings_quote_if_needed(theme));
|
|
append(*builder, "[window]\n");
|
|
append(*builder, "# Initial size, as a fraction of the monitor it opens on.\n");
|
|
print_to_builder(*builder, "width_fraction: %\n", FF(app_user_settings.window_width_fraction, 2));
|
|
print_to_builder(*builder, "height_fraction: %\n\n", FF(app_user_settings.window_height_fraction, 2));
|
|
append(*builder, "[font]\n");
|
|
append(*builder, "# Text size is size_base * scale_main * the monitor's DPI scale. The font size hotkeys change scale_main.\n");
|
|
print_to_builder(*builder, "size_base: %\n", FF(font_size_base, 1));
|
|
print_to_builder(*builder, "scale_main: %\n\n", FF(font_scale_main, 2));
|
|
append(*builder, "[ocr]\n");
|
|
append(*builder, "# The OCR model, by its name in ocr-models/models.config: ppocr-v5-mobile (light) or ppocr-v5-server (heavy).\n");
|
|
print_to_builder(*builder, "model: %\n\n", settings_quote_if_needed(ocr_model));
|
|
append(*builder, "[video]\n");
|
|
append(*builder, "# Screen recording (Ctrl+PrintScreen; Windows). fps: frames a second at most, written only when the\n");
|
|
append(*builder, "# screen changes. cursor: true or false. capture: auto (Desktop Duplication, else\n");
|
|
append(*builder, "# Windows.Graphics.Capture), dxgi or wgc. system_audio: what the computer plays; microphone: the\n");
|
|
append(*builder, "# default input device. Each true or false.\n");
|
|
print_to_builder(*builder, "fps: %\n", app_user_settings.video_fps);
|
|
print_to_builder(*builder, "cursor: %\n", ifx app_user_settings.video_cursor then "true" else "false");
|
|
print_to_builder(*builder, "capture: %\n", ifx app_user_settings.video_capture.count then app_user_settings.video_capture else "auto");
|
|
print_to_builder(*builder, "system_audio: %\n", ifx app_user_settings.video_system_audio then "true" else "false");
|
|
print_to_builder(*builder, "microphone: %\n\n", ifx app_user_settings.video_microphone then "true" else "false");
|
|
append(*builder, "[logging]\n");
|
|
append(*builder, "# Entries the Event Log keeps in memory. Past it the oldest are dropped; the log file keeps every line.\n");
|
|
print_to_builder(*builder, "event_log_capacity: %\n", app_user_settings.event_log_capacity);
|
|
|
|
// Hotkeys are only ever set by hand, so the sections are kept as written, comments and all; only
|
|
// actions neither bound nor explicitly "none" in the file are appended, so new ones reach
|
|
// existing users. Linux registers none, so its default is no section; one written by hand is
|
|
// still kept.
|
|
new_hotkey_count := 0;
|
|
|
|
global_hotkeys_text := ifx OS == .LINUX then "" else DEFAULT_GLOBAL_HOTKEYS_DATA;
|
|
if disk_ok && seen.global_hotkeys {
|
|
global_hotkeys_text = settings_section_text(disk_data, GLOBAL_HOTKEYS_SECTION);
|
|
if global_hotkeys_text.count && global_hotkeys_text[global_hotkeys_text.count - 1] != #char "\n"
|
|
global_hotkeys_text = tprint("%\n", global_hotkeys_text);
|
|
missing, active := hotkeys_missing_lines(global_hotkey_actions, on_disk.global_hotkey_bindings, global_hotkey_defaults(), disk_version);
|
|
global_hotkeys_text = tprint("%1%2", global_hotkeys_text, missing);
|
|
new_hotkey_count += active;
|
|
}
|
|
if global_hotkeys_text.count {
|
|
append(*builder, "\n");
|
|
append(*builder, global_hotkeys_text);
|
|
if global_hotkeys_text[global_hotkeys_text.count - 1] != #char "\n" append(*builder, "\n");
|
|
}
|
|
|
|
hotkeys_text := DEFAULT_HOTKEYS_DATA;
|
|
if disk_ok && seen.hotkeys {
|
|
hotkeys_text = settings_section_text(disk_data, HOTKEYS_SECTION);
|
|
if hotkeys_text.count && hotkeys_text[hotkeys_text.count - 1] != #char "\n"
|
|
hotkeys_text = tprint("%\n", hotkeys_text);
|
|
missing, active := hotkeys_missing_lines(hotkey_actions, on_disk.hotkey_bindings, hotkey_defaults(), disk_version);
|
|
hotkeys_text = tprint("%1%2", hotkeys_text, missing);
|
|
new_hotkey_count += active;
|
|
}
|
|
append(*builder, "\n");
|
|
append(*builder, hotkeys_text);
|
|
if hotkeys_text.count && hotkeys_text[hotkeys_text.count - 1] != #char "\n" append(*builder, "\n");
|
|
|
|
file_text := builder_to_string(*builder,, temp);
|
|
|
|
temp_path := tprint("%.tmp", path);
|
|
if !write_entire_file(temp_path, file_text) {
|
|
log_error("[settings] Could not write %.", temp_path);
|
|
return false;
|
|
}
|
|
if !atomic_file_replace_or_just_rename_if_destination_file_does_not_exist(temp_path, path) {
|
|
log_error("[settings] Could not move % to %.", temp_path, path);
|
|
return false;
|
|
}
|
|
|
|
// With an edit pending the old hash is kept, so the event for this write reloads the file,
|
|
// which now holds the edit merged with the app's changes, and applies it, hotkeys included.
|
|
if !edit_pending settings_file_hash = Hash.fnv1a_hash(file_text.data, file_text.count);
|
|
settings_note_synced();
|
|
|
|
if new_hotkey_count {
|
|
plural := ifx new_hotkey_count == 1 then "" else "s";
|
|
log("[settings] Added % new hotkey% with their default bindings to %.", new_hotkey_count, plural, path);
|
|
toast_show(.Success, "Added % new hotkey% to %; open it to see or change %",
|
|
new_hotkey_count, plural, APP_USER_SETTINGS_FILE_NAME, APP_USER_SETTINGS_FILE_NAME);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// From the header line up to the next section or the end of the file. "" when there is none.
|
|
settings_section_text :: (file_data: string, header: string) -> string {
|
|
start := -1;
|
|
offset: s64 = 0;
|
|
line: string;
|
|
while true {
|
|
line_start := offset;
|
|
if !theme_next_line(file_data, *offset, *line) break;
|
|
|
|
valid, stripped := strip_comment(line);
|
|
if !valid continue;
|
|
body := trim(stripped);
|
|
if !settings_is_section_header(body) continue;
|
|
|
|
if start >= 0 return slice(file_data, start, line_start - start);
|
|
if body == header start = line_start;
|
|
}
|
|
if start < 0 return "";
|
|
return slice(file_data, start, file_data.count - start);
|
|
}
|
|
|
|
// ---------------------------------------------------------------- live reload
|
|
|
|
// Applies settings.config when it differs from what was last loaded or written. Window size is
|
|
// taken in but only acts at the next start.
|
|
app_reload_user_settings_if_changed :: () {
|
|
path := app_user_settings_path();
|
|
file_data, read_ok := read_entire_file(path,, temp);
|
|
if !read_ok return; // being replaced, or deleted; the next event tries again
|
|
|
|
hash := Hash.fnv1a_hash(file_data.data, file_data.count);
|
|
if hash == settings_file_hash return;
|
|
settings_file_hash = hash;
|
|
|
|
fresh: App_User_Settings;
|
|
ok, seen, version := settings_parse(path, file_data, *fresh);
|
|
if !ok {
|
|
settings_free(*fresh);
|
|
log_error("[settings] Could not apply the changes to %; keeping the current settings.", path);
|
|
toast_show(.Error, "Could not apply the changes to %; see the Event Log", APP_USER_SETTINGS_FILE_NAME);
|
|
return;
|
|
}
|
|
|
|
old := app_user_settings;
|
|
app_user_settings = fresh;
|
|
defer settings_free(*old);
|
|
|
|
theme := ifx fresh.theme.count then fresh.theme else APP_DEFAULT_THEME_NAME;
|
|
current := app_theme_current();
|
|
if !current || current.name != theme {
|
|
if app_theme_find_index(theme) < 0 app_theme_refresh_registry(); // a theme file added since
|
|
app_theme_apply_name(theme);
|
|
}
|
|
|
|
if fresh.font_size_base != old.font_size_base || fresh.font_scale_main != old.font_scale_main {
|
|
app_apply_font_settings();
|
|
}
|
|
|
|
if fresh.event_log_capacity != old.event_log_capacity logger_set_event_log_capacity(fresh.event_log_capacity);
|
|
if fresh.ocr_model != old.ocr_model ocr_ui_settings_changed(); // app/ocr_ui.jai
|
|
|
|
bindings: [] Hotkey_Binding = hotkey_defaults();
|
|
if seen.hotkeys bindings = app_user_settings.hotkey_bindings;
|
|
hotkeys_install(hotkeys_with_missing_defaults(hotkey_actions, bindings, hotkey_defaults(), version));
|
|
|
|
global_bindings: [] Hotkey_Binding = global_hotkey_defaults();
|
|
if seen.global_hotkeys global_bindings = app_user_settings.global_hotkey_bindings;
|
|
global_hotkeys_install(hotkeys_with_missing_defaults(global_hotkey_actions, global_bindings, global_hotkey_defaults(), version));
|
|
global_hotkeys_reapply();
|
|
|
|
settings_note_synced();
|
|
log("[settings] Applied the changes to %.", path);
|
|
toast_show(.Success, "Applied the changes to %", APP_USER_SETTINGS_FILE_NAME);
|
|
}
|
|
|
|
// Opens a settings or theme file with the system's default editor for it.
|
|
app_open_config_file :: (path: string) {
|
|
platform_open_file_in_default_program(path, path_strip_filename(path));
|
|
}
|
|
|
|
settings_watch: struct {
|
|
watcher: Watcher.File_Watcher(void);
|
|
started: bool;
|
|
changed: bool;
|
|
}
|
|
|
|
app_settings_watch_start :: () {
|
|
dir := app_program_data_dir();
|
|
if !Watcher.init(*settings_watch.watcher, settings_watch_callback, watch_recursively = false) {
|
|
log_error("[settings] Could not watch %. Changes to % apply at the next start.", dir, APP_USER_SETTINGS_FILE_NAME);
|
|
return;
|
|
}
|
|
if !Watcher.add_directories(*settings_watch.watcher, dir) {
|
|
log_error("[settings] Could not watch %. Changes to % apply at the next start.", dir, APP_USER_SETTINGS_FILE_NAME);
|
|
Watcher.deinit(settings_watch.watcher);
|
|
return;
|
|
}
|
|
settings_watch.started = true;
|
|
}
|
|
|
|
app_settings_watch_frame :: () {
|
|
if !settings_watch.started return;
|
|
|
|
Watcher.process_changes(*settings_watch.watcher);
|
|
if !settings_watch.changed return;
|
|
|
|
settings_watch.changed = false;
|
|
app_reload_user_settings_if_changed();
|
|
}
|
|
|
|
app_settings_watch_stop :: () {
|
|
if !settings_watch.started return;
|
|
Watcher.deinit(settings_watch.watcher);
|
|
settings_watch.started = false;
|
|
}
|
|
|
|
// The whole directory is watched (on macOS recursively), so everything but the settings file is
|
|
// dropped. Editors save by rename as often as in place, so every event kind counts.
|
|
settings_watch_callback :: (watcher: *Watcher.File_Watcher(void), change: *Watcher.File_Change, user_data: *void) {
|
|
// A lost buffer on Windows arrives as SCAN_CHILDREN on the folder, not as an event for the file.
|
|
if change.events & (Watcher.File_Change_Event.OVERFLOW | .SCAN_CHILDREN) { settings_watch.changed = true; return; }
|
|
|
|
name := path_filename(change.full_path);
|
|
matches := ifx OS == .WINDOWS then equal_nocase(name, APP_USER_SETTINGS_FILE_NAME) else name == APP_USER_SETTINGS_FILE_NAME;
|
|
if matches settings_watch.changed = true;
|
|
}
|
|
|
|
#scope_file
|
|
|
|
Hash :: #import "Hash";
|
|
Watcher :: #import "File_Watcher";
|