// 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 // [export] folder, one line each: the folders Export offers for a capture's original, the first // the default. None means the Downloads folder. Heap-allocated. export_folders: [..] string; // [library] folder: the library, set by hand and read at startup; "" means LIBRARY_FOLDER_DEFAULT. // Heap-allocated. library_folder: string; // 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); for s.export_folders free(it,, context.default_allocator); array_free(s.export_folders); free(s.library_folder,, 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.export_folder && seen.library_folder && 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, export_folder, library_folder: 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; export_folders: string; // the list, a line each 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; // its strings 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.export_folders = join(..app_user_settings.export_folders, "\n",, temp); 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); free(settings_synced.export_folders,, 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); settings_synced.export_folders = copy_string(settings_synced.export_folders,, 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); for out.export_folders free(it,, context.default_allocator); out.export_folders.allocator = context.default_allocator; array_reset(*out.export_folders); 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 == "[export]" && key == "folder" { folder := settings_unquote(value); // an empty line says the list is empty if folder.count array_add(*out.export_folders, copy_string(folder,, context.default_allocator)); seen.export_folder = true; } else if section == "[library]" && key == "folder" { free(out.library_folder,, context.default_allocator); out.library_folder = copy_string(settings_unquote(value),, context.default_allocator); seen.library_folder = 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; export_folders: [] string = app_user_settings.export_folders; 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.export_folder && values.export_folders == settings_synced.export_folders export_folders = on_disk.export_folders; 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.library_folder { free(app_user_settings.library_folder,, context.default_allocator); app_user_settings.library_folder = copy_string(on_disk.library_folder,, context.default_allocator); } 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, "[export]\n"); append(*builder, "# The folders Export (in the Capture window) offers to write a capture to as it was imported, a\n"); append(*builder, "# folder: line each; the first is the default. None: the Downloads folder.\n"); for export_folders print_to_builder(*builder, "folder: %\n", settings_quote_if_needed(it)); if !export_folders append(*builder, "folder:\n"); append(*builder, "\n"); append(*builder, "[library]\n"); append(*builder, "# The folder of .mcap files mcap shows and imports into; ~/ is the home folder. Read when mcap\n"); append(*builder, "# starts; -library FOLDER on the command line overrides it.\n"); print_to_builder(*builder, "folder: %\n\n", settings_quote_if_needed(ifx app_user_settings.library_folder.count then app_user_settings.library_folder else LIBRARY_FOLDER_DEFAULT)); append(*builder, "[video]\n"); append(*builder, "# Screen recording (Ctrl+PrintScreen; Opt+Cmd+Shift+2 on macOS). fps: frames a second at most, written only when the\n"); append(*builder, "# screen changes. cursor: true or false. capture (Windows): 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 if fresh.library_folder != old.library_folder { log("[settings] The library folder is now %; mcap shows it from its next start.", ifx fresh.library_folder.count then fresh.library_folder else LIBRARY_FOLDER_DEFAULT); toast_show(.Success, "The new library folder is shown when mcap starts again"); } 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(); // macOS's FSEvents always watches recursively, and the module logs an error when asked not to. if !Watcher.init(*settings_watch.watcher, settings_watch_callback, watch_recursively = OS == .MACOS) { 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";