mcap/src/ui/imgui_theme_registry.jai
Musa Mahmood 4acde4d7a6 [0.1.10] Recording toolbar: a System sound button beside Mic
- System sound: on / off (red while on) turns what the computer plays on and off while
  recording, as Mic does the microphone; its state is saved as system_audio in [video].
- modules/Screen_Record: the microphone's switch is now one for either source:
  record_set_audio, record_audio_on and record_audio_failed take an Audio_Source_Kind, and the
  audio thread opens and closes each as it is turned on and off.
- record-probe -system-toggle. With -sync-test, system sound on at 3 s and off at 7 s keeps only
  the beeps at 3 and 5 s (8-12 ms from their flashes), and the track runs the whole recording.
2026-10-10 08:20:13 +01:00

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(bindings);
global_bindings: [] Hotkey_Binding = global_hotkey_defaults();
if ok && seen.global_hotkeys global_bindings = app_user_settings.global_hotkey_bindings;
global_hotkeys_install(global_bindings);
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, _ := 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(bindings);
global_bindings: [] Hotkey_Binding = global_hotkey_defaults();
if seen.global_hotkeys global_bindings = app_user_settings.global_hotkey_bindings;
global_hotkeys_install(global_bindings);
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";