yaze 0.3.2
Link to the Past ROM Editor
 
Loading...
Searching...
No Matches
user_settings.cc
Go to the documentation of this file.
2
3#include <algorithm>
4#include <array>
5#include <cmath>
6#include <filesystem>
7#include <fstream>
8#include <sstream>
9#include <string_view>
10#include <system_error>
11
12#include "absl/strings/str_format.h"
16#include "app/gui/core/style.h"
17#include "imgui/imgui.h"
18#include "util/file_util.h"
19#include "util/log.h"
20#include "util/platform_paths.h"
21
22#ifdef YAZE_WITH_JSON
23#include "nlohmann/json.hpp"
24#endif
25
26namespace yaze {
27namespace editor {
28
29#ifdef YAZE_WITH_JSON
30using json = nlohmann::json;
31#endif
32
33namespace {
34
35// Matches the Settings slider and View > Zoom range. A scale of 0 or NaN from a
36// hand-edited or truncated file makes every widget too small to use.
37constexpr float kMinFontGlobalScale = 0.5f;
38constexpr float kMaxFontGlobalScale = 2.0f;
39
40float SanitizeFontGlobalScale(float scale) {
41 if (!std::isfinite(scale)) {
42 return 1.0f;
43 }
44 return std::clamp(scale, kMinFontGlobalScale, kMaxFontGlobalScale);
45}
46
47absl::Status EnsureParentDirectory(const std::filesystem::path& path) {
48 auto parent = path.parent_path();
49 if (parent.empty()) {
50 return absl::OkStatus();
51 }
53}
54
64
72
74 std::unordered_map<std::string, bool>* panel_state) {
75 if (!panel_state) {
76 return;
77 }
78 for (auto it = panel_state->begin(); it != panel_state->end();) {
79 if (IsTransientPanelVisibilityId(it->first)) {
80 it = panel_state->erase(it);
81 } else {
82 ++it;
83 }
84 }
85}
86
87bool IsEmbeddedDungeonUtilityPanelId(const std::string& panel_id) {
88 return panel_id == "dungeon.object_editor" ||
89 panel_id == "dungeon.settings" || panel_id == "dungeon.dungeon_map";
90}
91
93 std::unordered_map<std::string, bool>* panel_state) {
94 if (!panel_state) {
95 return;
96 }
97 for (auto it = panel_state->begin(); it != panel_state->end();) {
98 if (IsEmbeddedDungeonUtilityPanelId(it->first)) {
99 it = panel_state->erase(it);
100 } else {
101 ++it;
102 }
103 }
104}
105
106constexpr std::array<const char*, 10> kDungeonWorkbenchDuplicatePanels = {
107 "dungeon.room_selector", "dungeon.room_matrix",
108 "dungeon.object_selector", "dungeon.sprite_editor",
109 "dungeon.item_editor", "dungeon.room_graphics",
110 "dungeon.door_editor", "dungeon.palette_editor",
111 "dungeon.entrance_list", "dungeon.entrance_properties",
112};
113
114constexpr std::array<const char*, 7> kDungeonDemotedUtilityPanels = {
115 "dungeon.custom_collision", "dungeon.water_fill",
116 "dungeon.room_tags", "dungeon.object_coverage",
117 "dungeon.object_tile_editor", "dungeon.overlay_manager",
118 "dungeon.minecart_tracks",
119};
120
122 std::unordered_map<std::string, bool>* panel_state) {
123 if (!panel_state) {
124 return;
125 }
126 (*panel_state)["dungeon.workbench"] = true;
127 for (const char* panel_id : kDungeonWorkbenchDuplicatePanels) {
128 (*panel_state)[panel_id] = false;
129 }
130}
131
133 std::unordered_map<std::string, bool>* panel_state) {
135 if (!panel_state) {
136 return;
137 }
138 for (const char* panel_id : kDungeonDemotedUtilityPanels) {
139 (*panel_state)[panel_id] = false;
140 }
141}
142
143// Windows the panel admission policy moved out of the window browser
144// (Diagnostic or EmbeddedTool). Each is still openable from its declared host
145// and the command palette. Revision 26 closes their persisted live visibility
146// so they stop reopening from an old session; pins, saved layouts, and named
147// layouts are explicit user customizations and are left alone.
149 const char* category;
150 const char* window_id;
151};
152
154 // Agent developer tooling: opened from Tools > Hack Workflows.
155 {"Agent", "agent.mesen_debug"},
156 {"Agent", "agent.mesen_screenshot"},
157 {"Agent", "agent.oracle_states"},
158 {"Agent", "agent.feature_flags"},
159 {"Agent", "agent.manifest"},
160 {"Agent", "agent.sram_viewer"},
161 // Oracle validation and inspection tooling: opened from Tools > Hack
162 // Workflows.
163 {"Agent", "oracle.validation"},
164 {"Agent", "oracle.menu_inspector"},
165 // Graphics research importer: opened from the welcome screen.
166 {"Graphics", "graphics.prototype_viewer"},
167 // Stub: draws nothing yet.
168 {"Screen", "screen.naming_screen"},
169 // Placeholder: shows "Coming Soon".
170 {"Emulator", "emulator.save_states"},
171};
172
174 std::unordered_map<std::string, std::unordered_map<std::string, bool>>*
175 visibility_state) {
176 if (!visibility_state) {
177 return;
178 }
179 for (const DemotedWindow& window : kRevision26DemotedWindows) {
180 auto category_it = visibility_state->find(window.category);
181 if (category_it == visibility_state->end()) {
182 continue;
183 }
184 auto window_it = category_it->second.find(window.window_id);
185 if (window_it != category_it->second.end()) {
186 window_it->second = false;
187 }
188 }
189}
190
192 if (!json_body || json_body->empty()) {
193 return false;
194 }
195 auto parsed = nlohmann::json::parse(*json_body, nullptr, false);
196 if (parsed.is_discarded() || !parsed.is_object() ||
197 !parsed.contains("root")) {
198 return false;
199 }
200
201 bool changed = false;
202 auto prune_node = [&changed](auto&& self, nlohmann::json& node) -> void {
203 if (!node.is_object()) {
204 return;
205 }
206 const std::string type = node.contains("type") && node["type"].is_string()
207 ? node["type"].get<std::string>()
208 : "";
209 if (type == "leaf" && node.contains("panels") &&
210 node["panels"].is_array()) {
211 auto& panels = node["panels"];
212 for (auto it = panels.begin(); it != panels.end();) {
213 const bool erase = it->is_object() && it->contains("panel_id") &&
214 (*it)["panel_id"].is_string() &&
216 (*it)["panel_id"].get<std::string>());
217 if (erase) {
218 it = panels.erase(it);
219 changed = true;
220 } else {
221 ++it;
222 }
223 }
224 const int max_active =
225 panels.empty() ? 0 : static_cast<int>(panels.size()) - 1;
226 const int active_tab_index =
227 node.contains("active_tab_index") &&
228 node["active_tab_index"].is_number_integer()
229 ? node["active_tab_index"].get<int>()
230 : 0;
231 const int clamped_active = std::clamp(active_tab_index, 0, max_active);
232 if (active_tab_index != clamped_active) {
233 node["active_tab_index"] = clamped_active;
234 changed = true;
235 }
236 }
237 if (node.contains("child_a")) {
238 self(self, node["child_a"]);
239 }
240 if (node.contains("child_b")) {
241 self(self, node["child_b"]);
242 }
243 };
244 prune_node(prune_node, parsed["root"]);
245
246 if (changed) {
247 *json_body = parsed.dump();
248 }
249 return changed;
250}
251
252absl::Status LoadPreferencesFromIni(const std::filesystem::path& path,
254 if (!prefs) {
255 return absl::InvalidArgumentError("prefs is null");
256 }
257
258 auto data = util::LoadFile(path.string());
259 if (data.empty()) {
260 return absl::OkStatus();
261 }
262
263 std::istringstream ss(data);
264 std::string line;
265 // Tolerate malformed numeric values in a partial or truncated INI: skip the
266 // bad value (keep the current default) instead of throwing out of Load(),
267 // which would abort before the defaults are written and the file re-saved.
268 auto to_float = [](const std::string& s, float fallback) {
269 try {
270 return std::stof(s);
271 } catch (const std::exception&) {
272 return fallback;
273 }
274 };
275 auto to_int = [](const std::string& s, int fallback) {
276 try {
277 return std::stoi(s);
278 } catch (const std::exception&) {
279 return fallback;
280 }
281 };
282 while (std::getline(ss, line)) {
283 size_t eq_pos = line.find('=');
284 if (eq_pos == std::string::npos) {
285 continue;
286 }
287
288 std::string key = line.substr(0, eq_pos);
289 std::string val = line.substr(eq_pos + 1);
290
291 // General
292 if (key == "font_global_scale") {
293 prefs->font_global_scale =
294 SanitizeFontGlobalScale(to_float(val, prefs->font_global_scale));
295 } else if (key == "backup_rom") {
296 prefs->backup_rom = (val == "1");
297 } else if (key == "save_new_auto") {
298 prefs->save_new_auto = (val == "1");
299 } else if (key == "autosave_enabled") {
300 prefs->autosave_enabled = (val == "1");
301 } else if (key == "autosave_interval") {
302 prefs->autosave_interval = to_float(val, prefs->autosave_interval);
303 } else if (key == "recent_files_limit") {
304 prefs->recent_files_limit = to_int(val, prefs->recent_files_limit);
305 } else if (key == "last_rom_path") {
306 prefs->last_rom_path = val;
307 } else if (key == "last_project_path") {
308 prefs->last_project_path = val;
309 } else if (key == "show_welcome_on_startup") {
310 prefs->show_welcome_on_startup = (val == "1");
311 } else if (key == "test_mode") {
312 prefs->test_mode = (val == "1");
313 } else if (key == "restore_last_session") {
314 prefs->restore_last_session = (val == "1");
315 } else if (key == "prefer_hmagic_sprite_names") {
316 prefs->prefer_hmagic_sprite_names = (val == "1");
317 } else if (key == "feature_hints_enabled") {
318 prefs->feature_hints_enabled = (val == "1");
319 } else if (constexpr std::string_view kDismissedHintPrefix =
320 "dismissed_feature_hint.";
321 key.rfind(kDismissedHintPrefix, 0) == 0) {
322 const std::string hint_id = key.substr(kDismissedHintPrefix.size());
323 if (!hint_id.empty() && val == "1") {
324 prefs->dismissed_feature_hints.insert(hint_id);
325 }
326 } else if (key == "welcome_triforce_alpha") {
328 to_float(val, prefs->welcome_triforce_alpha);
329 } else if (key == "welcome_triforce_speed") {
331 to_float(val, prefs->welcome_triforce_speed);
332 } else if (key == "welcome_triforce_size") {
333 prefs->welcome_triforce_size =
334 to_float(val, prefs->welcome_triforce_size);
335 } else if (key == "welcome_particles_enabled") {
336 prefs->welcome_particles_enabled = (val == "1");
337 } else if (key == "welcome_mouse_repel_enabled") {
338 prefs->welcome_mouse_repel_enabled = (val == "1");
339 } else if (key == "reduced_motion") {
340 prefs->reduced_motion = (val == "1");
341 } else if (key == "switch_motion_profile") {
342 prefs->switch_motion_profile = to_int(val, prefs->switch_motion_profile);
343 } else if (key == "last_theme_name") {
344 prefs->last_theme_name = val;
345 } else if (key == "language_locale") {
346 prefs->language_locale = val;
347 } else if (key == "font_family_index") {
348 prefs->font_family_index = to_int(val, prefs->font_family_index);
349 }
350 // Editor Behavior
351 else if (key == "backup_before_save") {
352 prefs->backup_before_save = (val == "1");
353 } else if (key == "default_editor") {
354 prefs->default_editor = to_int(val, prefs->default_editor);
355 }
356 // Performance
357 else if (key == "vsync") {
358 prefs->vsync = (val == "1");
359 } else if (key == "target_fps") {
360 prefs->target_fps = to_int(val, prefs->target_fps);
361 } else if (key == "cache_size_mb") {
362 prefs->cache_size_mb = to_int(val, prefs->cache_size_mb);
363 } else if (key == "undo_history_size") {
364 prefs->undo_history_size = to_int(val, prefs->undo_history_size);
365 }
366 // AI Agent
367 else if (key == "ai_provider") {
368 prefs->ai_provider = to_int(val, prefs->ai_provider);
369 } else if (key == "ai_model") {
370 prefs->ai_model = val;
371 } else if (key == "ollama_url") {
372 prefs->ollama_url = val;
373 } else if (key == "gemini_api_key") {
374 prefs->gemini_api_key = val;
375 } else if (key == "openai_api_key") {
376 prefs->openai_api_key = val;
377 } else if (key == "anthropic_api_key") {
378 prefs->anthropic_api_key = val;
379 } else if (key == "ai_temperature") {
380 prefs->ai_temperature = to_float(val, prefs->ai_temperature);
381 } else if (key == "ai_max_tokens") {
382 prefs->ai_max_tokens = to_int(val, prefs->ai_max_tokens);
383 } else if (key == "ai_proactive") {
384 prefs->ai_proactive = (val == "1");
385 } else if (key == "ai_auto_learn") {
386 prefs->ai_auto_learn = (val == "1");
387 } else if (key == "ai_multimodal") {
388 prefs->ai_multimodal = (val == "1");
389 }
390 // CLI Logging
391 else if (key == "log_level") {
392 prefs->log_level = to_int(val, prefs->log_level);
393 } else if (key == "log_to_file") {
394 prefs->log_to_file = (val == "1");
395 } else if (key == "log_file_path") {
396 prefs->log_file_path = val;
397 } else if (key == "log_ai_requests") {
398 prefs->log_ai_requests = (val == "1");
399 } else if (key == "log_rom_operations") {
400 prefs->log_rom_operations = (val == "1");
401 } else if (key == "log_gui_automation") {
402 prefs->log_gui_automation = (val == "1");
403 } else if (key == "log_proposals") {
404 prefs->log_proposals = (val == "1");
405 }
406 // Panel Shortcuts (format: panel_shortcut.panel_id=shortcut)
407 else if (key.substr(0, 15) == "panel_shortcut.") {
408 std::string panel_id = key.substr(15);
409 prefs->panel_shortcuts[panel_id] = val;
410 }
411 // Backward compatibility for card_shortcut
412 else if (key.substr(0, 14) == "card_shortcut.") {
413 std::string panel_id = key.substr(14);
414 prefs->panel_shortcuts[panel_id] = val;
415 }
416 // Sidebar State
417 else if (key == "sidebar_visible") {
418 prefs->sidebar_visible = (val == "1");
419 } else if (key == "sidebar_panel_expanded") {
420 prefs->sidebar_panel_expanded = (val == "1");
421 } else if (key == "sidebar_panel_width") {
422 prefs->sidebar_panel_width = to_float(val, prefs->sidebar_panel_width);
423 } else if (key == "panel_browser_category_width") {
425 to_float(val, prefs->panel_browser_category_width);
426 } else if (key == "panel_layout_defaults_revision") {
428 to_int(val, prefs->panel_layout_defaults_revision);
429 } else if (key == "sidebar_active_category") {
430 prefs->sidebar_active_category = val;
431 } else if (key.rfind("sidebar_context_collapsed.", 0) == 0) {
432 const std::string category =
433 key.substr(std::string("sidebar_context_collapsed.").size());
434 if (!category.empty()) {
435 if (val == "1") {
436 prefs->sidebar_context_collapsed.insert(category);
437 } else {
438 prefs->sidebar_context_collapsed.erase(category);
439 }
440 }
441 } else if (key == "dungeon_inspector_side") {
443 (val == "left") ? std::string("left") : std::string("right");
444 }
445 // Status Bar
446 else if (key == "show_status_bar") {
447 prefs->show_status_bar = (val == "1");
448 } else if (key == "emulator_keep_running_in_background") {
449 prefs->emulator_keep_running_in_background = (val == "1");
450 } else if (key == "show_experimental_editors") {
451 prefs->show_experimental_editors = (val == "1");
452 }
453 // Panel Visibility State (format: panel_visibility.EditorType.panel_id=1)
454 else if (key.substr(0, 17) == "panel_visibility.") {
455 std::string rest = key.substr(17);
456 size_t dot_pos = rest.find('.');
457 if (dot_pos != std::string::npos) {
458 std::string editor_type = rest.substr(0, dot_pos);
459 std::string panel_id = rest.substr(dot_pos + 1);
460 prefs->panel_visibility_state[editor_type][panel_id] = (val == "1");
461 }
462 }
463 // Pinned Panels (format: pinned_panel.panel_id=1)
464 else if (key.substr(0, 13) == "pinned_panel.") {
465 std::string panel_id = key.substr(13);
466 prefs->pinned_panels[panel_id] = (val == "1");
467 }
468 // Right panel widths (format: right_panel_width.panel_key=420.0)
469 else if (key.substr(0, 18) == "right_panel_width.") {
470 std::string panel_key = key.substr(18);
471 prefs->right_panel_widths[panel_key] = to_float(val, 0.0f);
472 }
473 // Saved Layouts (format: saved_layout.LayoutName.panel_id=1)
474 else if (key.substr(0, 13) == "saved_layout.") {
475 std::string rest = key.substr(13);
476 size_t dot_pos = rest.find('.');
477 if (dot_pos != std::string::npos) {
478 std::string layout_name = rest.substr(0, dot_pos);
479 std::string panel_id = rest.substr(dot_pos + 1);
480 prefs->saved_layouts[layout_name][panel_id] = (val == "1");
481 }
482 }
483 // Named DockTree layouts (format: named_layout.<name>=<compact-json>).
484 // Value is a single-line JSON document produced by DockTreeToJson().dump();
485 // callers re-parse it through DockTreeFromJson when needed.
486 else if (key.substr(0, 13) == "named_layout.") {
487 std::string layout_name = key.substr(13);
488 prefs->named_layouts[layout_name] = val;
489 } else if (key == "last_applied_layout_name") {
490 prefs->last_applied_layout_name = val;
491 }
492 // Remembered ROM load options (format: rom_load_choice=<choice>\t<path>).
493 // The choice comes first because it never holds a tab; the path may hold
494 // '=' or a tab, so it takes the rest of the line.
495 else if (key == "rom_load_choice") {
496 const size_t tab = val.find('\t');
497 if (tab != std::string::npos && tab + 1 < val.size()) {
498 prefs->rom_load_choices[val.substr(tab + 1)] = val.substr(0, tab);
499 }
500 }
501 }
502
503 return absl::OkStatus();
504}
505
506absl::Status SavePreferencesToIni(const std::filesystem::path& path,
507 const UserSettings::Preferences& prefs) {
508 auto ensure_status = EnsureParentDirectory(path);
509 if (!ensure_status.ok()) {
510 return ensure_status;
511 }
512
513 std::ostringstream ss;
514 // General
515 ss << "font_global_scale=" << prefs.font_global_scale << "\n";
516 ss << "backup_rom=" << (prefs.backup_rom ? 1 : 0) << "\n";
517 ss << "save_new_auto=" << (prefs.save_new_auto ? 1 : 0) << "\n";
518 ss << "autosave_enabled=" << (prefs.autosave_enabled ? 1 : 0) << "\n";
519 ss << "autosave_interval=" << prefs.autosave_interval << "\n";
520 ss << "recent_files_limit=" << prefs.recent_files_limit << "\n";
521 ss << "last_rom_path=" << prefs.last_rom_path << "\n";
522 ss << "last_project_path=" << prefs.last_project_path << "\n";
523 ss << "show_welcome_on_startup=" << (prefs.show_welcome_on_startup ? 1 : 0)
524 << "\n";
525 ss << "test_mode=" << (prefs.test_mode ? 1 : 0) << "\n";
526 ss << "restore_last_session=" << (prefs.restore_last_session ? 1 : 0) << "\n";
527 ss << "prefer_hmagic_sprite_names="
528 << (prefs.prefer_hmagic_sprite_names ? 1 : 0) << "\n";
529 ss << "feature_hints_enabled=" << (prefs.feature_hints_enabled ? 1 : 0)
530 << "\n";
531 std::vector<std::string> dismissed_hints(
532 prefs.dismissed_feature_hints.begin(),
533 prefs.dismissed_feature_hints.end());
534 std::sort(dismissed_hints.begin(), dismissed_hints.end());
535 for (const auto& hint_id : dismissed_hints) {
536 ss << "dismissed_feature_hint." << hint_id << "=1\n";
537 }
538 ss << "welcome_triforce_alpha=" << prefs.welcome_triforce_alpha << "\n";
539 ss << "welcome_triforce_speed=" << prefs.welcome_triforce_speed << "\n";
540 ss << "welcome_triforce_size=" << prefs.welcome_triforce_size << "\n";
541 ss << "welcome_particles_enabled="
542 << (prefs.welcome_particles_enabled ? 1 : 0) << "\n";
543 ss << "welcome_mouse_repel_enabled="
544 << (prefs.welcome_mouse_repel_enabled ? 1 : 0) << "\n";
545 ss << "reduced_motion=" << (prefs.reduced_motion ? 1 : 0) << "\n";
546 ss << "switch_motion_profile=" << prefs.switch_motion_profile << "\n";
547 ss << "last_theme_name=" << prefs.last_theme_name << "\n";
548 ss << "language_locale=" << prefs.language_locale << "\n";
549 ss << "font_family_index=" << prefs.font_family_index << "\n";
550
551 // Editor Behavior
552 ss << "backup_before_save=" << (prefs.backup_before_save ? 1 : 0) << "\n";
553 ss << "default_editor=" << prefs.default_editor << "\n";
554
555 // Performance
556 ss << "vsync=" << (prefs.vsync ? 1 : 0) << "\n";
557 ss << "target_fps=" << prefs.target_fps << "\n";
558 ss << "cache_size_mb=" << prefs.cache_size_mb << "\n";
559 ss << "undo_history_size=" << prefs.undo_history_size << "\n";
560
561 // AI Agent
562 ss << "ai_provider=" << prefs.ai_provider << "\n";
563 ss << "ai_model=" << prefs.ai_model << "\n";
564 ss << "ollama_url=" << prefs.ollama_url << "\n";
565 ss << "gemini_api_key=" << prefs.gemini_api_key << "\n";
566 ss << "openai_api_key=" << prefs.openai_api_key << "\n";
567 ss << "anthropic_api_key=" << prefs.anthropic_api_key << "\n";
568 ss << "ai_temperature=" << prefs.ai_temperature << "\n";
569 ss << "ai_max_tokens=" << prefs.ai_max_tokens << "\n";
570 ss << "ai_proactive=" << (prefs.ai_proactive ? 1 : 0) << "\n";
571 ss << "ai_auto_learn=" << (prefs.ai_auto_learn ? 1 : 0) << "\n";
572 ss << "ai_multimodal=" << (prefs.ai_multimodal ? 1 : 0) << "\n";
573
574 // CLI Logging
575 ss << "log_level=" << prefs.log_level << "\n";
576 ss << "log_to_file=" << (prefs.log_to_file ? 1 : 0) << "\n";
577 ss << "log_file_path=" << prefs.log_file_path << "\n";
578 ss << "log_ai_requests=" << (prefs.log_ai_requests ? 1 : 0) << "\n";
579 ss << "log_rom_operations=" << (prefs.log_rom_operations ? 1 : 0) << "\n";
580 ss << "log_gui_automation=" << (prefs.log_gui_automation ? 1 : 0) << "\n";
581 ss << "log_proposals=" << (prefs.log_proposals ? 1 : 0) << "\n";
582
583 // Panel Shortcuts
584 for (const auto& [panel_id, shortcut] : prefs.panel_shortcuts) {
585 ss << "panel_shortcut." << panel_id << "=" << shortcut << "\n";
586 }
587
588 // Sidebar State
589 ss << "sidebar_visible=" << (prefs.sidebar_visible ? 1 : 0) << "\n";
590 ss << "sidebar_panel_expanded=" << (prefs.sidebar_panel_expanded ? 1 : 0)
591 << "\n";
592 ss << "sidebar_panel_width=" << prefs.sidebar_panel_width << "\n";
593 ss << "panel_browser_category_width=" << prefs.panel_browser_category_width
594 << "\n";
595 ss << "panel_layout_defaults_revision="
596 << prefs.panel_layout_defaults_revision << "\n";
597 ss << "sidebar_active_category=" << prefs.sidebar_active_category << "\n";
598 for (const std::string& category : prefs.sidebar_context_collapsed) {
599 ss << "sidebar_context_collapsed." << category << "=1\n";
600 }
601 ss << "dungeon_inspector_side=" << prefs.dungeon_inspector_side << "\n";
602
603 // Status Bar
604 ss << "show_status_bar=" << (prefs.show_status_bar ? 1 : 0) << "\n";
605 ss << "emulator_keep_running_in_background="
606 << (prefs.emulator_keep_running_in_background ? 1 : 0) << "\n";
607 ss << "show_experimental_editors="
608 << (prefs.show_experimental_editors ? 1 : 0) << "\n";
609
610 // Panel Visibility State
611 for (const auto& [editor_type, panel_state] : prefs.panel_visibility_state) {
612 for (const auto& [panel_id, visible] : panel_state) {
613 ss << "panel_visibility." << editor_type << "." << panel_id << "="
614 << (visible ? 1 : 0) << "\n";
615 }
616 }
617
618 // Pinned Panels
619 for (const auto& [panel_id, pinned] : prefs.pinned_panels) {
620 ss << "pinned_panel." << panel_id << "=" << (pinned ? 1 : 0) << "\n";
621 }
622
623 for (const auto& [panel_key, width] : prefs.right_panel_widths) {
624 ss << "right_panel_width." << panel_key << "=" << width << "\n";
625 }
626
627 // Saved Layouts
628 for (const auto& [layout_name, panel_state] : prefs.saved_layouts) {
629 for (const auto& [panel_id, visible] : panel_state) {
630 ss << "saved_layout." << layout_name << "." << panel_id << "="
631 << (visible ? 1 : 0) << "\n";
632 }
633 }
634
635 // Named DockTree layouts (Layout Designer).
636 if (!prefs.last_applied_layout_name.empty()) {
637 ss << "last_applied_layout_name=" << prefs.last_applied_layout_name << "\n";
638 }
639 for (const auto& [layout_name, json_body] : prefs.named_layouts) {
640 // JSON bodies are expected to be compact (no newlines) — filter just in
641 // case the caller handed us an indented dump.
642 std::string single_line;
643 single_line.reserve(json_body.size());
644 for (char c : json_body) {
645 if (c == '\n' || c == '\r')
646 continue;
647 single_line.push_back(c);
648 }
649 ss << "named_layout." << layout_name << "=" << single_line << "\n";
650 }
651
652 // Remembered ROM load options. A newline in either part would split the
653 // line, and a tab in the choice would move the separator; skip both.
654 for (const auto& [rom_path, choice] : prefs.rom_load_choices) {
655 if (rom_path.find_first_of("\r\n") != std::string::npos ||
656 choice.find_first_of("\t\r\n") != std::string::npos) {
657 continue;
658 }
659 ss << "rom_load_choice=" << choice << "\t" << rom_path << "\n";
660 }
661
662 std::ofstream file(path);
663 if (!file.is_open()) {
664 return absl::InternalError(
665 absl::StrFormat("Failed to open settings file: %s", path.string()));
666 }
667 file << ss.str();
668 return absl::OkStatus();
669}
670
671#ifdef YAZE_WITH_JSON
672void EnsureDefaultAiHosts(UserSettings::Preferences* prefs) {
673 if (!prefs) {
674 return;
675 }
676
677 if (!prefs->ai_hosts.empty()) {
678 if (prefs->active_ai_host_id.empty()) {
679 prefs->active_ai_host_id = prefs->ai_hosts.front().id;
680 }
681 return;
682 }
683
684 if (!prefs->ollama_url.empty()) {
685 UserSettings::Preferences::AiHost host;
686 host.id = "ollama-local";
687 host.label = "Ollama (local)";
688 host.base_url = prefs->ollama_url;
689 host.api_type = "ollama";
690 host.supports_tools = true;
691 host.supports_streaming = true;
692 prefs->ai_hosts.push_back(host);
693 }
694
695 // Provide a local OpenAI-compatible host for LM Studio by default.
696 UserSettings::Preferences::AiHost lmstudio;
697 lmstudio.id = "lmstudio-local";
698 lmstudio.label = "LM Studio (local)";
699 lmstudio.base_url = "http://localhost:1234";
700 lmstudio.api_type = "lmstudio";
701 lmstudio.supports_tools = true;
702 lmstudio.supports_streaming = true;
703 prefs->ai_hosts.push_back(lmstudio);
704
705 if (!prefs->ai_hosts.empty() && prefs->active_ai_host_id.empty()) {
706 prefs->active_ai_host_id = prefs->ai_hosts.front().id;
707 }
708}
709
710void EnsureDefaultAiProfiles(UserSettings::Preferences* prefs) {
711 if (!prefs) {
712 return;
713 }
714 if (!prefs->ai_profiles.empty()) {
715 if (prefs->active_ai_profile.empty()) {
716 prefs->active_ai_profile = prefs->ai_profiles.front().name;
717 }
718 return;
719 }
720 if (!prefs->ai_model.empty()) {
721 UserSettings::Preferences::AiModelProfile profile;
722 profile.name = "default";
723 profile.model = prefs->ai_model;
724 profile.temperature = prefs->ai_temperature;
725 profile.top_p = 0.95f;
726 profile.max_output_tokens = prefs->ai_max_tokens;
727 profile.supports_tools = true;
728 prefs->ai_profiles.push_back(profile);
729 prefs->active_ai_profile = profile.name;
730 }
731}
732
733void EnsureDefaultFilesystemRoots(UserSettings::Preferences* prefs) {
734 if (!prefs) {
735 return;
736 }
737
738 auto add_unique_root = [&](const std::filesystem::path& path) {
739 if (path.empty()) {
740 return;
741 }
742 const std::string path_str = path.string();
743 auto it = std::find(prefs->project_root_paths.begin(),
744 prefs->project_root_paths.end(), path_str);
745 if (it == prefs->project_root_paths.end()) {
746 prefs->project_root_paths.push_back(path_str);
747 }
748 };
749
751 if (docs_dir.ok()) {
752 add_unique_root(*docs_dir);
753 }
754
755 if (prefs->use_icloud_sync) {
756 auto icloud_dir =
758 if (icloud_dir.ok()) {
759 add_unique_root(*icloud_dir);
760 if (prefs->default_project_root.empty()) {
761 prefs->default_project_root = icloud_dir->string();
762 }
763 }
764 }
765
766 if (prefs->default_project_root.empty() &&
767 !prefs->project_root_paths.empty()) {
768 prefs->default_project_root = prefs->project_root_paths.front();
769 }
770}
771
772void EnsureDefaultModelPaths(UserSettings::Preferences* prefs) {
773 if (!prefs) {
774 return;
775 }
776 if (!prefs->ai_model_paths.empty()) {
777 return;
778 }
779
780 auto add_unique_path = [&](const std::filesystem::path& path) {
781 if (path.empty()) {
782 return;
783 }
784 const std::string path_str = path.string();
785 auto it = std::find(prefs->ai_model_paths.begin(),
786 prefs->ai_model_paths.end(), path_str);
787 if (it == prefs->ai_model_paths.end()) {
788 prefs->ai_model_paths.push_back(path_str);
789 }
790 };
791
792 const auto home_dir = util::PlatformPaths::GetHomeDirectory();
793 if (!home_dir.empty() && home_dir != ".") {
794 add_unique_path(home_dir / "models");
795 add_unique_path(home_dir / ".lmstudio" / "models");
796 add_unique_path(home_dir / ".ollama" / "models");
797 }
798}
799
800void LoadStringMap(const json& src,
801 std::unordered_map<std::string, std::string>* target) {
802 if (!target || !src.is_object()) {
803 return;
804 }
805 target->clear();
806 for (const auto& [key, value] : src.items()) {
807 if (value.is_string()) {
808 (*target)[key] = value.get<std::string>();
809 }
810 }
811}
812
813void LoadBoolMap(const json& src,
814 std::unordered_map<std::string, bool>* target) {
815 if (!target || !src.is_object()) {
816 return;
817 }
818 target->clear();
819 for (const auto& [key, value] : src.items()) {
820 if (value.is_boolean()) {
821 (*target)[key] = value.get<bool>();
822 }
823 }
824}
825
826void LoadFloatMap(const json& src,
827 std::unordered_map<std::string, float>* target) {
828 if (!target || !src.is_object()) {
829 return;
830 }
831 target->clear();
832 for (const auto& [key, value] : src.items()) {
833 if (value.is_number()) {
834 (*target)[key] = value.get<float>();
835 }
836 }
837}
838
839void LoadNestedBoolMap(
840 const json& src,
841 std::unordered_map<std::string, std::unordered_map<std::string, bool>>*
842 target) {
843 if (!target || !src.is_object()) {
844 return;
845 }
846 target->clear();
847 for (const auto& [outer_key, outer_val] : src.items()) {
848 if (!outer_val.is_object()) {
849 continue;
850 }
851 auto& inner = (*target)[outer_key];
852 inner.clear();
853 for (const auto& [inner_key, inner_val] : outer_val.items()) {
854 if (inner_val.is_boolean()) {
855 inner[inner_key] = inner_val.get<bool>();
856 }
857 }
858 }
859}
860
861json ToStringMap(const std::unordered_map<std::string, std::string>& map) {
862 json obj = json::object();
863 for (const auto& [key, value] : map) {
864 obj[key] = value;
865 }
866 return obj;
867}
868
869json ToBoolMap(const std::unordered_map<std::string, bool>& map) {
870 json obj = json::object();
871 for (const auto& [key, value] : map) {
872 obj[key] = value;
873 }
874 return obj;
875}
876
877json ToFloatMap(const std::unordered_map<std::string, float>& map) {
878 json obj = json::object();
879 for (const auto& [key, value] : map) {
880 obj[key] = value;
881 }
882 return obj;
883}
884
885json ToNestedBoolMap(const std::unordered_map<
886 std::string, std::unordered_map<std::string, bool>>& map) {
887 json obj = json::object();
888 for (const auto& [outer_key, inner] : map) {
889 obj[outer_key] = ToBoolMap(inner);
890 }
891 return obj;
892}
893
894absl::Status LoadPreferencesFromJson(const std::filesystem::path& path,
895 UserSettings::Preferences* prefs) {
896 if (!prefs) {
897 return absl::InvalidArgumentError("prefs is null");
898 }
899
900 std::ifstream file(path);
901 if (!file.is_open()) {
902 return absl::NotFoundError(
903 absl::StrFormat("Settings file not found: %s", path.string()));
904 }
905
906 json root;
907 try {
908 file >> root;
909 } catch (const std::exception& e) {
910 return absl::InternalError(
911 absl::StrFormat("Failed to parse settings.json: %s", e.what()));
912 }
913
914 if (root.contains("general")) {
915 const auto& g = root["general"];
916 prefs->font_global_scale = SanitizeFontGlobalScale(
917 g.value("font_global_scale", prefs->font_global_scale));
918 prefs->backup_rom = g.value("backup_rom", prefs->backup_rom);
919 prefs->save_new_auto = g.value("save_new_auto", prefs->save_new_auto);
920 prefs->autosave_enabled =
921 g.value("autosave_enabled", prefs->autosave_enabled);
922 prefs->autosave_interval =
923 g.value("autosave_interval", prefs->autosave_interval);
924 prefs->recent_files_limit =
925 g.value("recent_files_limit", prefs->recent_files_limit);
926 prefs->last_rom_path = g.value("last_rom_path", prefs->last_rom_path);
927 prefs->last_project_path =
928 g.value("last_project_path", prefs->last_project_path);
929 prefs->show_welcome_on_startup =
930 g.value("show_welcome_on_startup", prefs->show_welcome_on_startup);
931 prefs->test_mode = g.value("test_mode", prefs->test_mode);
932 prefs->restore_last_session =
933 g.value("restore_last_session", prefs->restore_last_session);
934 prefs->prefer_hmagic_sprite_names = g.value(
935 "prefer_hmagic_sprite_names", prefs->prefer_hmagic_sprite_names);
936 prefs->welcome_triforce_alpha =
937 g.value("welcome_triforce_alpha", prefs->welcome_triforce_alpha);
938 prefs->welcome_triforce_speed =
939 g.value("welcome_triforce_speed", prefs->welcome_triforce_speed);
940 prefs->welcome_triforce_size =
941 g.value("welcome_triforce_size", prefs->welcome_triforce_size);
942 prefs->welcome_particles_enabled =
943 g.value("welcome_particles_enabled", prefs->welcome_particles_enabled);
944 prefs->welcome_mouse_repel_enabled = g.value(
945 "welcome_mouse_repel_enabled", prefs->welcome_mouse_repel_enabled);
946 }
947
948 if (root.contains("onboarding")) {
949 const auto& onboarding = root["onboarding"];
950 prefs->feature_hints_enabled =
951 onboarding.value("feature_hints_enabled", prefs->feature_hints_enabled);
952 if (onboarding.contains("dismissed_feature_hints") &&
953 onboarding["dismissed_feature_hints"].is_array()) {
954 prefs->dismissed_feature_hints.clear();
955 for (const auto& item : onboarding["dismissed_feature_hints"]) {
956 if (item.is_string()) {
957 prefs->dismissed_feature_hints.insert(item.get<std::string>());
958 }
959 }
960 }
961 }
962
963 if (root.contains("appearance")) {
964 const auto& appearance = root["appearance"];
965 prefs->reduced_motion =
966 appearance.value("reduced_motion", prefs->reduced_motion);
967 prefs->switch_motion_profile =
968 appearance.value("switch_motion_profile", prefs->switch_motion_profile);
969 prefs->last_theme_name =
970 appearance.value("last_theme_name", prefs->last_theme_name);
971 prefs->language_locale =
972 appearance.value("language_locale", prefs->language_locale);
973 prefs->font_family_index =
974 appearance.value("font_family_index", prefs->font_family_index);
975 }
976
977 if (root.contains("editor")) {
978 const auto& e = root["editor"];
979 prefs->backup_before_save =
980 e.value("backup_before_save", prefs->backup_before_save);
981 prefs->default_editor = e.value("default_editor", prefs->default_editor);
982 {
983 std::string side =
984 e.value("dungeon_inspector_side", prefs->dungeon_inspector_side);
985 prefs->dungeon_inspector_side = (side == "left") ? "left" : "right";
986 }
987 }
988
989 if (root.contains("performance")) {
990 const auto& p = root["performance"];
991 prefs->vsync = p.value("vsync", prefs->vsync);
992 prefs->target_fps = p.value("target_fps", prefs->target_fps);
993 prefs->cache_size_mb = p.value("cache_size_mb", prefs->cache_size_mb);
994 prefs->undo_history_size =
995 p.value("undo_history_size", prefs->undo_history_size);
996 }
997
998 if (root.contains("ai")) {
999 const auto& ai = root["ai"];
1000 prefs->ai_provider = ai.value("provider", prefs->ai_provider);
1001 prefs->ai_model = ai.value("model", prefs->ai_model);
1002 prefs->ollama_url = ai.value("ollama_url", prefs->ollama_url);
1003 prefs->gemini_api_key = ai.value("gemini_api_key", prefs->gemini_api_key);
1004 prefs->openai_api_key = ai.value("openai_api_key", prefs->openai_api_key);
1005 prefs->anthropic_api_key =
1006 ai.value("anthropic_api_key", prefs->anthropic_api_key);
1007 std::string google_key = ai.value("google_api_key", std::string());
1008 if (prefs->gemini_api_key.empty() && !google_key.empty()) {
1009 prefs->gemini_api_key = google_key;
1010 }
1011 prefs->ai_temperature = ai.value("temperature", prefs->ai_temperature);
1012 prefs->ai_max_tokens = ai.value("max_tokens", prefs->ai_max_tokens);
1013 prefs->ai_proactive = ai.value("proactive", prefs->ai_proactive);
1014 prefs->ai_auto_learn = ai.value("auto_learn", prefs->ai_auto_learn);
1015 prefs->ai_multimodal = ai.value("multimodal", prefs->ai_multimodal);
1016 prefs->active_ai_host_id =
1017 ai.value("active_host_id", prefs->active_ai_host_id);
1018 prefs->active_ai_profile =
1019 ai.value("active_profile", prefs->active_ai_profile);
1020 prefs->remote_build_host_id =
1021 ai.value("remote_build_host_id", prefs->remote_build_host_id);
1022 if (ai.contains("model_paths") && ai["model_paths"].is_array()) {
1023 prefs->ai_model_paths.clear();
1024 for (const auto& item : ai["model_paths"]) {
1025 if (item.is_string()) {
1026 prefs->ai_model_paths.push_back(item.get<std::string>());
1027 }
1028 }
1029 }
1030
1031 if (ai.contains("hosts") && ai["hosts"].is_array()) {
1032 prefs->ai_hosts.clear();
1033 for (const auto& host : ai["hosts"]) {
1034 if (!host.is_object()) {
1035 continue;
1036 }
1037 UserSettings::Preferences::AiHost entry;
1038 entry.id = host.value("id", "");
1039 entry.label = host.value("label", "");
1040 entry.base_url = host.value("base_url", "");
1041 entry.api_type = host.value("api_type", "");
1042 entry.supports_vision =
1043 host.value("supports_vision", entry.supports_vision);
1044 entry.supports_tools =
1045 host.value("supports_tools", entry.supports_tools);
1046 entry.supports_streaming =
1047 host.value("supports_streaming", entry.supports_streaming);
1048 entry.allow_insecure =
1049 host.value("allow_insecure", entry.allow_insecure);
1050 entry.api_key = host.value("api_key", "");
1051 entry.credential_id = host.value("credential_id", "");
1052 prefs->ai_hosts.push_back(entry);
1053 }
1054 }
1055
1056 if (ai.contains("profiles") && ai["profiles"].is_array()) {
1057 prefs->ai_profiles.clear();
1058 for (const auto& profile : ai["profiles"]) {
1059 if (!profile.is_object()) {
1060 continue;
1061 }
1062 UserSettings::Preferences::AiModelProfile entry;
1063 entry.name = profile.value("name", "");
1064 entry.model = profile.value("model", "");
1065 entry.temperature = profile.value("temperature", entry.temperature);
1066 entry.top_p = profile.value("top_p", entry.top_p);
1067 entry.max_output_tokens =
1068 profile.value("max_output_tokens", entry.max_output_tokens);
1069 entry.supports_vision =
1070 profile.value("supports_vision", entry.supports_vision);
1071 entry.supports_tools =
1072 profile.value("supports_tools", entry.supports_tools);
1073 prefs->ai_profiles.push_back(entry);
1074 }
1075 }
1076 }
1077
1078 if (root.contains("logging")) {
1079 const auto& log = root["logging"];
1080 prefs->log_level = log.value("level", prefs->log_level);
1081 prefs->log_to_file = log.value("to_file", prefs->log_to_file);
1082 prefs->log_file_path = log.value("file_path", prefs->log_file_path);
1083 prefs->log_ai_requests = log.value("ai_requests", prefs->log_ai_requests);
1084 prefs->log_rom_operations =
1085 log.value("rom_operations", prefs->log_rom_operations);
1086 prefs->log_gui_automation =
1087 log.value("gui_automation", prefs->log_gui_automation);
1088 prefs->log_proposals = log.value("proposals", prefs->log_proposals);
1089 }
1090
1091 if (root.contains("shortcuts")) {
1092 const auto& shortcuts = root["shortcuts"];
1093 if (shortcuts.contains("panel")) {
1094 LoadStringMap(shortcuts["panel"], &prefs->panel_shortcuts);
1095 }
1096 if (shortcuts.contains("global")) {
1097 LoadStringMap(shortcuts["global"], &prefs->global_shortcuts);
1098 }
1099 if (shortcuts.contains("editor")) {
1100 LoadStringMap(shortcuts["editor"], &prefs->editor_shortcuts);
1101 }
1102 }
1103
1104 if (root.contains("sidebar")) {
1105 const auto& sidebar = root["sidebar"];
1106 prefs->sidebar_visible = sidebar.value("visible", prefs->sidebar_visible);
1107 prefs->sidebar_panel_expanded =
1108 sidebar.value("panel_expanded", prefs->sidebar_panel_expanded);
1109 prefs->sidebar_panel_width =
1110 sidebar.value("panel_width", prefs->sidebar_panel_width);
1111 prefs->panel_browser_category_width = sidebar.value(
1112 "panel_browser_category_width", prefs->panel_browser_category_width);
1113 prefs->sidebar_active_category =
1114 sidebar.value("active_category", prefs->sidebar_active_category);
1115
1116 if (sidebar.contains("order") && sidebar["order"].is_array()) {
1117 prefs->sidebar_order.clear();
1118 for (const auto& item : sidebar["order"]) {
1119 if (item.is_string()) {
1120 prefs->sidebar_order.push_back(item.get<std::string>());
1121 }
1122 }
1123 }
1124 if (sidebar.contains("hidden") && sidebar["hidden"].is_array()) {
1125 prefs->sidebar_hidden.clear();
1126 for (const auto& item : sidebar["hidden"]) {
1127 if (item.is_string()) {
1128 prefs->sidebar_hidden.insert(item.get<std::string>());
1129 }
1130 }
1131 }
1132 if (sidebar.contains("pinned") && sidebar["pinned"].is_array()) {
1133 prefs->sidebar_pinned.clear();
1134 for (const auto& item : sidebar["pinned"]) {
1135 if (item.is_string()) {
1136 prefs->sidebar_pinned.insert(item.get<std::string>());
1137 }
1138 }
1139 }
1140 if (sidebar.contains("context_collapsed") &&
1141 sidebar["context_collapsed"].is_array()) {
1142 prefs->sidebar_context_collapsed.clear();
1143 for (const auto& item : sidebar["context_collapsed"]) {
1144 if (item.is_string()) {
1145 prefs->sidebar_context_collapsed.insert(item.get<std::string>());
1146 }
1147 }
1148 }
1149 }
1150
1151 if (root.contains("status_bar")) {
1152 const auto& status_bar = root["status_bar"];
1153 prefs->show_status_bar =
1154 status_bar.value("visible", prefs->show_status_bar);
1155 }
1156
1157 if (root.contains("emulator")) {
1158 const auto& emulator = root["emulator"];
1159 prefs->emulator_keep_running_in_background =
1160 emulator.value("keep_running_in_background",
1161 prefs->emulator_keep_running_in_background);
1162 }
1163
1164 if (root.contains("editors")) {
1165 const auto& editors = root["editors"];
1166 prefs->show_experimental_editors =
1167 editors.value("show_experimental", prefs->show_experimental_editors);
1168 }
1169
1170 if (root.contains("layouts")) {
1171 const auto& layouts = root["layouts"];
1172 prefs->panel_layout_defaults_revision = layouts.value(
1173 "defaults_revision", prefs->panel_layout_defaults_revision);
1174 if (layouts.contains("panel_visibility")) {
1175 LoadNestedBoolMap(layouts["panel_visibility"],
1176 &prefs->panel_visibility_state);
1177 }
1178 if (layouts.contains("pinned_panels")) {
1179 LoadBoolMap(layouts["pinned_panels"], &prefs->pinned_panels);
1180 }
1181 if (layouts.contains("right_panel_widths")) {
1182 LoadFloatMap(layouts["right_panel_widths"], &prefs->right_panel_widths);
1183 }
1184 if (layouts.contains("saved_layouts")) {
1185 LoadNestedBoolMap(layouts["saved_layouts"], &prefs->saved_layouts);
1186 }
1187 if (layouts.contains("named_layouts") &&
1188 layouts["named_layouts"].is_object()) {
1189 prefs->named_layouts.clear();
1190 for (auto it = layouts["named_layouts"].begin();
1191 it != layouts["named_layouts"].end(); ++it) {
1192 if (it.value().is_string()) {
1193 prefs->named_layouts[it.key()] = it.value().get<std::string>();
1194 } else if (it.value().is_object() || it.value().is_array()) {
1195 // Preferred shape: embed the DockTree as a nested JSON object for
1196 // human-readability. Re-serialize to compact string for storage.
1197 prefs->named_layouts[it.key()] = it.value().dump();
1198 }
1199 }
1200 }
1201 prefs->last_applied_layout_name = layouts.value(
1202 "last_applied_layout_name", prefs->last_applied_layout_name);
1203 }
1204
1205 if (root.contains("rom_load_choices")) {
1206 LoadStringMap(root["rom_load_choices"], &prefs->rom_load_choices);
1207 }
1208
1209 if (root.contains("filesystem")) {
1210 const auto& fs = root["filesystem"];
1211 if (fs.contains("project_root_paths") &&
1212 fs["project_root_paths"].is_array()) {
1213 prefs->project_root_paths.clear();
1214 for (const auto& item : fs["project_root_paths"]) {
1215 if (item.is_string()) {
1216 prefs->project_root_paths.push_back(item.get<std::string>());
1217 }
1218 }
1219 }
1220 prefs->default_project_root =
1221 fs.value("default_project_root", prefs->default_project_root);
1222 prefs->use_files_app = fs.value("use_files_app", prefs->use_files_app);
1223 prefs->use_icloud_sync =
1224 fs.value("use_icloud_sync", prefs->use_icloud_sync);
1225 }
1226
1227 EnsureDefaultAiHosts(prefs);
1228 EnsureDefaultAiProfiles(prefs);
1229 EnsureDefaultFilesystemRoots(prefs);
1230
1231 return absl::OkStatus();
1232}
1233
1234absl::Status SavePreferencesToJson(const std::filesystem::path& path,
1235 const UserSettings::Preferences& prefs) {
1236 auto ensure_status = EnsureParentDirectory(path);
1237 if (!ensure_status.ok()) {
1238 return ensure_status;
1239 }
1240
1241 json root;
1242 root["version"] = 1;
1243 root["general"] = {
1244 {"font_global_scale", prefs.font_global_scale},
1245 {"backup_rom", prefs.backup_rom},
1246 {"save_new_auto", prefs.save_new_auto},
1247 {"autosave_enabled", prefs.autosave_enabled},
1248 {"autosave_interval", prefs.autosave_interval},
1249 {"recent_files_limit", prefs.recent_files_limit},
1250 {"last_rom_path", prefs.last_rom_path},
1251 {"last_project_path", prefs.last_project_path},
1252 {"show_welcome_on_startup", prefs.show_welcome_on_startup},
1253 {"test_mode", prefs.test_mode},
1254 {"restore_last_session", prefs.restore_last_session},
1255 {"prefer_hmagic_sprite_names", prefs.prefer_hmagic_sprite_names},
1256 {"welcome_triforce_alpha", prefs.welcome_triforce_alpha},
1257 {"welcome_triforce_speed", prefs.welcome_triforce_speed},
1258 {"welcome_triforce_size", prefs.welcome_triforce_size},
1259 {"welcome_particles_enabled", prefs.welcome_particles_enabled},
1260 {"welcome_mouse_repel_enabled", prefs.welcome_mouse_repel_enabled},
1261 };
1262
1263 {
1264 auto set_to_sorted_vec = [](const std::unordered_set<std::string>& s)
1265 -> std::vector<std::string> {
1266 std::vector<std::string> v(s.begin(), s.end());
1267 std::sort(v.begin(), v.end());
1268 return v;
1269 };
1270 root["onboarding"] = {
1271 {"feature_hints_enabled", prefs.feature_hints_enabled},
1272 {"dismissed_feature_hints",
1273 set_to_sorted_vec(prefs.dismissed_feature_hints)},
1274 };
1275 }
1276
1277 root["appearance"] = {
1278 {"reduced_motion", prefs.reduced_motion},
1279 {"switch_motion_profile", prefs.switch_motion_profile},
1280 {"last_theme_name", prefs.last_theme_name},
1281 {"language_locale", prefs.language_locale},
1282 {"font_family_index", prefs.font_family_index},
1283 };
1284
1285 root["editor"] = {
1286 {"backup_before_save", prefs.backup_before_save},
1287 {"default_editor", prefs.default_editor},
1288 {"dungeon_inspector_side", prefs.dungeon_inspector_side},
1289 };
1290
1291 root["performance"] = {
1292 {"vsync", prefs.vsync},
1293 {"target_fps", prefs.target_fps},
1294 {"cache_size_mb", prefs.cache_size_mb},
1295 {"undo_history_size", prefs.undo_history_size},
1296 };
1297
1298 json ai_hosts = json::array();
1299 for (const auto& host : prefs.ai_hosts) {
1300 ai_hosts.push_back({
1301 {"id", host.id},
1302 {"label", host.label},
1303 {"base_url", host.base_url},
1304 {"api_type", host.api_type},
1305 {"supports_vision", host.supports_vision},
1306 {"supports_tools", host.supports_tools},
1307 {"supports_streaming", host.supports_streaming},
1308 {"allow_insecure", host.allow_insecure},
1309 {"api_key", host.api_key},
1310 {"credential_id", host.credential_id},
1311 });
1312 }
1313
1314 json ai_profiles = json::array();
1315 for (const auto& profile : prefs.ai_profiles) {
1316 ai_profiles.push_back({
1317 {"name", profile.name},
1318 {"model", profile.model},
1319 {"temperature", profile.temperature},
1320 {"top_p", profile.top_p},
1321 {"max_output_tokens", profile.max_output_tokens},
1322 {"supports_vision", profile.supports_vision},
1323 {"supports_tools", profile.supports_tools},
1324 });
1325 }
1326
1327 root["ai"] = {
1328 {"provider", prefs.ai_provider},
1329 {"model", prefs.ai_model},
1330 {"ollama_url", prefs.ollama_url},
1331 {"gemini_api_key", prefs.gemini_api_key},
1332 {"google_api_key", prefs.gemini_api_key},
1333 {"openai_api_key", prefs.openai_api_key},
1334 {"anthropic_api_key", prefs.anthropic_api_key},
1335 {"temperature", prefs.ai_temperature},
1336 {"max_tokens", prefs.ai_max_tokens},
1337 {"proactive", prefs.ai_proactive},
1338 {"auto_learn", prefs.ai_auto_learn},
1339 {"multimodal", prefs.ai_multimodal},
1340 {"hosts", ai_hosts},
1341 {"active_host_id", prefs.active_ai_host_id},
1342 {"profiles", ai_profiles},
1343 {"active_profile", prefs.active_ai_profile},
1344 {"remote_build_host_id", prefs.remote_build_host_id},
1345 {"model_paths", prefs.ai_model_paths},
1346 };
1347
1348 root["logging"] = {
1349 {"level", prefs.log_level},
1350 {"to_file", prefs.log_to_file},
1351 {"file_path", prefs.log_file_path},
1352 {"ai_requests", prefs.log_ai_requests},
1353 {"rom_operations", prefs.log_rom_operations},
1354 {"gui_automation", prefs.log_gui_automation},
1355 {"proposals", prefs.log_proposals},
1356 };
1357
1358 root["shortcuts"] = {
1359 {"panel", ToStringMap(prefs.panel_shortcuts)},
1360 {"global", ToStringMap(prefs.global_shortcuts)},
1361 {"editor", ToStringMap(prefs.editor_shortcuts)},
1362 };
1363
1364 auto set_to_sorted_vec =
1365 [](const std::unordered_set<std::string>& s) -> std::vector<std::string> {
1366 std::vector<std::string> v(s.begin(), s.end());
1367 std::sort(v.begin(), v.end());
1368 return v;
1369 };
1370
1371 root["sidebar"] = {
1372 {"visible", prefs.sidebar_visible},
1373 {"panel_expanded", prefs.sidebar_panel_expanded},
1374 {"panel_width", prefs.sidebar_panel_width},
1375 {"panel_browser_category_width", prefs.panel_browser_category_width},
1376 {"active_category", prefs.sidebar_active_category},
1377 {"order", prefs.sidebar_order},
1378 {"hidden", set_to_sorted_vec(prefs.sidebar_hidden)},
1379 {"pinned", set_to_sorted_vec(prefs.sidebar_pinned)},
1380 {"context_collapsed", set_to_sorted_vec(prefs.sidebar_context_collapsed)},
1381 };
1382
1383 root["status_bar"] = {
1384 {"visible", prefs.show_status_bar},
1385 };
1386
1387 root["emulator"] = {
1388 {"keep_running_in_background", prefs.emulator_keep_running_in_background},
1389 };
1390
1391 root["editors"] = {
1392 {"show_experimental", prefs.show_experimental_editors},
1393 };
1394
1395 // Emit named_layouts as an object of parsed JSON objects so the settings
1396 // file stays human-readable. Malformed or non-object bodies are stored as
1397 // the raw string so the next load can try to recover them.
1398 nlohmann::json named_layouts_json = nlohmann::json::object();
1399 for (const auto& [layout_name, json_body] : prefs.named_layouts) {
1400 nlohmann::json parsed = nlohmann::json::parse(json_body, nullptr, false);
1401 if (parsed.is_discarded() || !parsed.is_object()) {
1402 named_layouts_json[layout_name] = json_body;
1403 } else {
1404 named_layouts_json[layout_name] = std::move(parsed);
1405 }
1406 }
1407
1408 root["layouts"] = {
1409 {"defaults_revision", prefs.panel_layout_defaults_revision},
1410 {"panel_visibility", ToNestedBoolMap(prefs.panel_visibility_state)},
1411 {"pinned_panels", ToBoolMap(prefs.pinned_panels)},
1412 {"right_panel_widths", ToFloatMap(prefs.right_panel_widths)},
1413 {"saved_layouts", ToNestedBoolMap(prefs.saved_layouts)},
1414 {"named_layouts", std::move(named_layouts_json)},
1415 {"last_applied_layout_name", prefs.last_applied_layout_name},
1416 };
1417
1418 root["rom_load_choices"] = ToStringMap(prefs.rom_load_choices);
1419
1420 root["filesystem"] = {
1421 {"project_root_paths", prefs.project_root_paths},
1422 {"default_project_root", prefs.default_project_root},
1423 {"use_files_app", prefs.use_files_app},
1424 {"use_icloud_sync", prefs.use_icloud_sync},
1425 };
1426
1427 std::ofstream file(path);
1428 if (!file.is_open()) {
1429 return absl::InternalError(
1430 absl::StrFormat("Failed to open settings file: %s", path.string()));
1431 }
1432
1433 file << root.dump(2) << "\n";
1434 return absl::OkStatus();
1435}
1436#endif // YAZE_WITH_JSON
1437
1438} // namespace
1439
1441 auto docs_dir_status = util::PlatformPaths::GetUserDocumentsDirectory();
1442 auto config_dir_status = util::PlatformPaths::GetConfigDirectory();
1443 if (docs_dir_status.ok()) {
1444 settings_file_path_ = (*docs_dir_status / "settings.json").string();
1445 } else if (config_dir_status.ok()) {
1446 settings_file_path_ = (*config_dir_status / "settings.json").string();
1447 } else {
1448 LOG_WARN("UserSettings",
1449 "Could not determine user documents or config directory. Using "
1450 "local settings.json.");
1451 settings_file_path_ = "settings.json";
1452 }
1453
1454 if (config_dir_status.ok()) {
1456 (*config_dir_status / "yaze_settings.ini").string();
1457 } else {
1458 legacy_settings_file_path_ = "yaze_settings.ini";
1459 }
1460}
1461
1462absl::Status UserSettings::Load() {
1463 try {
1464 bool loaded = false;
1465#ifdef YAZE_WITH_JSON
1467 if (json_exists) {
1468 auto status = LoadPreferencesFromJson(settings_file_path_, &prefs_);
1469 if (status.ok()) {
1470 loaded = true;
1471 } else {
1472 LOG_WARN("UserSettings", "Failed to load settings.json: %s",
1473 status.ToString().c_str());
1474 // Preserve the unreadable file as settings.json.bak before the defaults
1475 // below overwrite it via Save(), so the user can recover it.
1476 std::error_code ec;
1477 std::filesystem::rename(settings_file_path_,
1478 settings_file_path_ + ".bak", ec);
1479 if (ec) {
1480 LOG_WARN("UserSettings", "Could not back up settings.json: %s",
1481 ec.message().c_str());
1482 }
1483 }
1484 }
1485#endif
1486
1488 auto status = LoadPreferencesFromIni(legacy_settings_file_path_, &prefs_);
1489 if (!status.ok()) {
1490 return status;
1491 }
1492 loaded = true;
1493#ifdef YAZE_WITH_JSON
1495 (void)SavePreferencesToJson(settings_file_path_, prefs_);
1496 }
1497#endif
1498 }
1499
1500 if (!loaded) {
1501#if defined(__APPLE__) && \
1502 (TARGET_OS_IPHONE == 1 || TARGET_IPHONE_SIMULATOR == 1)
1503 prefs_.sidebar_visible = false;
1505#endif
1506 LOG_INFO("UserSettings", "Settings not found, creating defaults at: %s",
1507 settings_file_path_.c_str());
1508 return Save();
1509 }
1510
1511#ifdef YAZE_WITH_JSON
1512 EnsureDefaultAiHosts(&prefs_);
1513 EnsureDefaultAiProfiles(&prefs_);
1514 EnsureDefaultFilesystemRoots(&prefs_);
1515 EnsureDefaultModelPaths(&prefs_);
1516#endif
1517
1519 std::clamp(prefs_.switch_motion_profile, 0, 2);
1520
1521 if (ImGui::GetCurrentContext() != nullptr) {
1522 ImGui::GetIO().FontGlobalScale = prefs_.font_global_scale;
1523 } else {
1524 LOG_WARN("UserSettings",
1525 "ImGui context not available; skipping FontGlobalScale update");
1526 }
1527 } catch (const std::exception& e) {
1528 return absl::InternalError(
1529 absl::StrFormat("Failed to load user settings: %s", e.what()));
1530 }
1531 return absl::OkStatus();
1532}
1533
1535 if (target_revision <= 0) {
1536 return false;
1537 }
1538
1539 bool applied = false;
1540
1541 if (prefs_.panel_layout_defaults_revision < 4 && target_revision >= 4) {
1542 prefs_.sidebar_visible = true;
1547
1549 prefs_.pinned_panels.clear();
1550 prefs_.right_panel_widths.clear();
1551 prefs_.saved_layouts.clear();
1552
1554 applied = true;
1555 }
1556
1557 if (prefs_.panel_layout_defaults_revision < 5 && target_revision >= 5) {
1558 auto overworld_it = prefs_.panel_visibility_state.find("Overworld");
1559 if (overworld_it != prefs_.panel_visibility_state.end()) {
1560 overworld_it->second["overworld.tile16_editor"] = false;
1561 }
1563 applied = true;
1564 }
1565
1566 if (prefs_.panel_layout_defaults_revision < 6 && target_revision >= 6) {
1567 auto overworld_it = prefs_.panel_visibility_state.find("Overworld");
1568 if (overworld_it != prefs_.panel_visibility_state.end()) {
1569 auto& overworld_windows = overworld_it->second;
1570 overworld_windows["overworld.canvas"] = true;
1571 overworld_windows["overworld.tile16_selector"] = true;
1572 overworld_windows["overworld.properties"] = true;
1573 overworld_windows["overworld.tile16_editor"] = false;
1574 overworld_windows["overworld.tile8_selector"] = false;
1575 overworld_windows["overworld.area_graphics"] = false;
1576 overworld_windows["overworld.item_list"] = false;
1577 }
1579 applied = true;
1580 }
1581
1582 // Revision 7: WindowLifecycle::Persistent collapsed into CrossEditor.
1583 // Force-pin the two former-Persistent panels on upgrade so always-visible
1584 // behavior carries through. We must overwrite an existing pinned=false
1585 // entry here: under the old Persistent regime that value was a silent no-op
1586 // (the draw loop ignored pin state for Persistent panels), so treating it
1587 // as a "user choice" post-collapse would be a regression, not preservation.
1588 // After the migration runs once, subsequent unpin actions ARE load-bearing
1589 // and persist normally.
1590 if (prefs_.panel_layout_defaults_revision < 7 && target_revision >= 7) {
1591 prefs_.pinned_panels["agent.oracle_ram"] = true;
1592 prefs_.pinned_panels["workflow.output"] = true;
1594 applied = true;
1595 }
1596
1597 if (prefs_.panel_layout_defaults_revision < 8 && target_revision >= 8) {
1598 auto dungeon_it = prefs_.panel_visibility_state.find("Dungeon");
1599 if (dungeon_it != prefs_.panel_visibility_state.end()) {
1600 auto& dungeon_windows = dungeon_it->second;
1601 dungeon_windows["dungeon.workbench"] = true;
1602 dungeon_windows["dungeon.room_selector"] = false;
1603 dungeon_windows["dungeon.room_matrix"] = true;
1604 dungeon_windows["dungeon.object_editor"] = true;
1605 dungeon_windows["dungeon.room_graphics"] = true;
1606 dungeon_windows["dungeon.palette_editor"] = true;
1607 }
1609 applied = true;
1610 }
1611
1612 if (prefs_.panel_layout_defaults_revision < 9 && target_revision >= 9) {
1613 auto dungeon_it = prefs_.panel_visibility_state.find("Dungeon");
1614 if (dungeon_it != prefs_.panel_visibility_state.end()) {
1615 auto& dungeon_windows = dungeon_it->second;
1616 dungeon_windows["dungeon.door_editor"] = true;
1617 }
1619 applied = true;
1620 }
1621
1622 if (prefs_.panel_layout_defaults_revision < 10 && target_revision >= 10) {
1623 auto graphics_it = prefs_.panel_visibility_state.find("Graphics");
1624 if (graphics_it != prefs_.panel_visibility_state.end()) {
1625 auto& graphics_windows = graphics_it->second;
1626 graphics_windows["graphics.prototype_viewer"] = true;
1627 }
1629 applied = true;
1630 }
1631
1632 if (prefs_.panel_layout_defaults_revision < 11 && target_revision >= 11) {
1633 auto dungeon_it = prefs_.panel_visibility_state.find("Dungeon");
1634 if (dungeon_it != prefs_.panel_visibility_state.end()) {
1635 auto& dungeon_windows = dungeon_it->second;
1636 const bool legacy_object_surface =
1637 dungeon_windows.contains("dungeon.object_editor")
1638 ? dungeon_windows["dungeon.object_editor"]
1639 : true;
1640 dungeon_windows["dungeon.object_selector"] = legacy_object_surface;
1641 }
1643 applied = true;
1644 }
1645
1646 if (prefs_.panel_layout_defaults_revision < 12 && target_revision >= 12) {
1647 auto graphics_it = prefs_.panel_visibility_state.find("Graphics");
1648 if (graphics_it != prefs_.panel_visibility_state.end()) {
1649 auto& graphics_windows = graphics_it->second;
1650 graphics_windows["graphics.polyhedral"] = false;
1651 }
1653 applied = true;
1654 }
1655
1656 if (prefs_.panel_layout_defaults_revision < 13 && target_revision >= 13) {
1657 auto dungeon_it = prefs_.panel_visibility_state.find("Dungeon");
1658 if (dungeon_it != prefs_.panel_visibility_state.end()) {
1659 auto& dungeon_windows = dungeon_it->second;
1660 dungeon_windows["dungeon.workbench"] = true;
1661 dungeon_windows["dungeon.room_selector"] = false;
1662 dungeon_windows["dungeon.object_selector"] = true;
1663 dungeon_windows["dungeon.object_editor"] = true;
1664 dungeon_windows["dungeon.room_graphics"] = true;
1665 dungeon_windows["dungeon.room_matrix"] = true;
1666 dungeon_windows["dungeon.palette_editor"] = true;
1667 dungeon_windows["dungeon.door_editor"] = false;
1668 }
1670 applied = true;
1671 }
1672
1673 if (prefs_.panel_layout_defaults_revision < 14 && target_revision >= 14) {
1674 if (prefs_.last_theme_name == "YAZE Tre") {
1675 prefs_.last_theme_name = "Classic YAZE";
1676 }
1677
1678 auto dungeon_it = prefs_.panel_visibility_state.find("Dungeon");
1679 if (dungeon_it != prefs_.panel_visibility_state.end()) {
1680 auto& dungeon_windows = dungeon_it->second;
1681 dungeon_windows["dungeon.object_tile_editor"] = false;
1682 dungeon_windows["dungeon.settings"] = false;
1683 dungeon_windows["dungeon.dungeon_map"] = false;
1684 }
1685
1687 applied = true;
1688 }
1689
1690 if (prefs_.panel_layout_defaults_revision < 15 && target_revision >= 15) {
1691 auto dungeon_it = prefs_.panel_visibility_state.find("Dungeon");
1692 if (dungeon_it != prefs_.panel_visibility_state.end()) {
1693 auto& dungeon_windows = dungeon_it->second;
1694 dungeon_windows["dungeon.object_selector"] = true;
1695 dungeon_windows["dungeon.room_graphics"] = false;
1696 }
1698 applied = true;
1699 }
1700
1701 // Revision 16: best-effort lift each visibility-only `saved_layouts` entry
1702 // into a flat single-leaf DockTree under `named_layouts`. Users who want
1703 // a custom dock arrangement re-author it in the Layout Designer; this
1704 // migration only preserves which panels were in the set, not where they
1705 // were docked (that information never existed in the old format).
1706 if (prefs_.panel_layout_defaults_revision < 16 && target_revision >= 16) {
1707 for (const auto& [layout_name, panel_state] : prefs_.saved_layouts) {
1708 if (prefs_.named_layouts.count(layout_name) != 0) {
1709 continue; // Don't overwrite a DockTree the user already has.
1710 }
1711 layout_designer::DockTree tree(layout_name);
1712 std::vector<layout_designer::PanelEntry> panels;
1713 panels.reserve(panel_state.size());
1714 for (const auto& [panel_id, visible] : panel_state) {
1715 if (!visible)
1716 continue;
1717 panels.push_back({panel_id, /*display_name=*/"", /*icon=*/""});
1718 }
1719 tree.root = layout_designer::DockNode::MakeLeaf(std::move(panels));
1720 prefs_.named_layouts[layout_name] =
1722 }
1724 applied = true;
1725 }
1726
1727 // Revision 17: default-pin the Layout Designer so "Show: Layout Designer"
1728 // from the command palette is drawable from any editor context, not just
1729 // when the active_category matches. Mirrors the rev-7 pattern for
1730 // former-Persistent panels (agent.oracle_ram, workflow.output). A user
1731 // who later unpins the panel keeps that choice — the block only runs
1732 // once on upgrade.
1733 if (prefs_.panel_layout_defaults_revision < 17 && target_revision >= 17) {
1734 prefs_.pinned_panels["layout.designer"] = true;
1736 applied = true;
1737 }
1738
1739 // Revision 18: stop carrying standalone dungeon room windows across app
1740 // launches. Workbench mode is now the default dungeon workflow; stale
1741 // `dungeon.room_*` visibility entries can resurrect dozens of room panels
1742 // and make the UI look broken immediately after switching to Dungeon.
1743 if (prefs_.panel_layout_defaults_revision < 18 && target_revision >= 18) {
1744 if (auto dungeon_it = prefs_.panel_visibility_state.find("Dungeon");
1745 dungeon_it != prefs_.panel_visibility_state.end()) {
1746 EraseTransientPanelVisibility(&dungeon_it->second);
1747 }
1748 for (auto it = prefs_.pinned_panels.begin();
1749 it != prefs_.pinned_panels.end();) {
1750 if (IsTransientPanelVisibilityId(it->first)) {
1751 it = prefs_.pinned_panels.erase(it);
1752 } else {
1753 ++it;
1754 }
1755 }
1757 applied = true;
1758 }
1759
1760 // Revision 19: Selection Inspector, Dungeon Settings, and Dungeon Map are no
1761 // longer high-level dungeon panels. Their controls live in the Workbench
1762 // inspector or its transient map popup, so stale persisted panel IDs should
1763 // not resurrect empty/missing windows.
1764 if (prefs_.panel_layout_defaults_revision < 19 && target_revision >= 19) {
1765 if (auto dungeon_it = prefs_.panel_visibility_state.find("Dungeon");
1766 dungeon_it != prefs_.panel_visibility_state.end()) {
1767 dungeon_it->second["dungeon.workbench"] = true;
1768 EraseEmbeddedDungeonUtilityPanelVisibility(&dungeon_it->second);
1769 }
1770 for (auto it = prefs_.pinned_panels.begin();
1771 it != prefs_.pinned_panels.end();) {
1772 if (IsEmbeddedDungeonUtilityPanelId(it->first)) {
1773 it = prefs_.pinned_panels.erase(it);
1774 } else {
1775 ++it;
1776 }
1777 }
1778 for (auto& [layout_name, panel_state] : prefs_.saved_layouts) {
1779 (void)layout_name;
1780 EraseEmbeddedDungeonUtilityPanelVisibility(&panel_state);
1781 }
1782 for (auto& [layout_name, json_body] : prefs_.named_layouts) {
1783 (void)layout_name;
1784 (void)PruneEmbeddedDungeonUtilityPanelsFromDockTreeJson(&json_body);
1785 }
1787 applied = true;
1788 }
1789
1790 // Revision 20: keep the Overworld editor selector-first on startup. Earlier
1791 // migrations hid Tile16 Editor from defaults, but persisted visibility state
1792 // from affected sessions can still reopen it before the user asks for it.
1793 if (prefs_.panel_layout_defaults_revision < 20 && target_revision >= 20) {
1794 if (auto overworld_it = prefs_.panel_visibility_state.find("Overworld");
1795 overworld_it != prefs_.panel_visibility_state.end()) {
1796 overworld_it->second["overworld.tile16_editor"] = false;
1797 }
1799 applied = true;
1800 }
1801
1802 // Revision 21: Layout C (ZScream-style) defaults for the Dungeon editor.
1803 // Open the three left-stack selectors (Object/Sprite/Item) plus the Room
1804 // Browser/Entrances surface (`dungeon.room_selector`) and Room Matrix in
1805 // addition to the workbench so a first-run / migrated user lands on a
1806 // ZScream-shaped layout. The actual L/R placement is decided by the
1807 // workbench window itself (it inspects `dungeon_inspector_side` at draw
1808 // time) and by ImGui's docking — no `left_panel_widths` map exists, and
1809 // adding one is out of scope for this slice. The inspector-pane width hint
1810 // is seeded into `right_panel_widths` for the workbench window so users see
1811 // a sensibly-sized inspector before they drag.
1812 if (prefs_.panel_layout_defaults_revision < 21 && target_revision >= 21) {
1813 if (auto dungeon_it = prefs_.panel_visibility_state.find("Dungeon");
1814 dungeon_it != prefs_.panel_visibility_state.end()) {
1815 auto& dungeon_windows = dungeon_it->second;
1816 dungeon_windows["dungeon.workbench"] = true;
1817 dungeon_windows["dungeon.object_selector"] = true;
1818 dungeon_windows["dungeon.sprite_editor"] = true;
1819 dungeon_windows["dungeon.item_editor"] = true;
1820 dungeon_windows["dungeon.room_selector"] = true;
1821 dungeon_windows["dungeon.room_matrix"] = true;
1822 }
1823
1824 // Default Layout C placement is selectors-left / inspector-right. Only
1825 // seed the value when the user has not already chosen one (empty string
1826 // from a pre-rev-21 settings file) so a future preference flip isn't
1827 // clobbered by the migration.
1828 if (prefs_.dungeon_inspector_side.empty()) {
1830 }
1831
1832 // Seed sensible widths for the workbench inspector pane. These are hints;
1833 // the user's drag persists through `right_panel_widths` once they adjust.
1834 if (!prefs_.right_panel_widths.contains("dungeon.workbench")) {
1835 prefs_.right_panel_widths["dungeon.workbench"] = 320.0f;
1836 }
1837
1839 applied = true;
1840 }
1841
1842 // Revision 22: make the Dungeon Workbench the single default surface.
1843 // These standalone panels duplicate tools already embedded in the
1844 // Workbench and collectively squeeze the room canvas. Close only the live
1845 // Dungeon visibility entries; pinned panels and saved/named layouts are
1846 // explicit user customizations and must remain untouched.
1847 if (prefs_.panel_layout_defaults_revision < 22 && target_revision >= 22) {
1848 if (auto dungeon_it = prefs_.panel_visibility_state.find("Dungeon");
1849 dungeon_it != prefs_.panel_visibility_state.end()) {
1850 ApplyDungeonWorkbenchVisibilityDefaults(&dungeon_it->second);
1851 }
1852
1854 applied = true;
1855 }
1856
1857 // Revision 23: keep the global activity rail available, but collapse its
1858 // wide tool catalog when Dungeon is the active/default workspace. The
1859 // Workbench already embeds the useful room browser, canvas, and inspector;
1860 // showing both left panes steals enough width for responsive layout to hide
1861 // the room browser. Re-assert the revision-22 visibility set because an older
1862 // concurrently-running build can persist those duplicate windows again.
1863 if (prefs_.panel_layout_defaults_revision < 23 && target_revision >= 23) {
1864 if (auto dungeon_it = prefs_.panel_visibility_state.find("Dungeon");
1865 dungeon_it != prefs_.panel_visibility_state.end()) {
1866 ApplyDungeonWorkbenchVisibilityDefaults(&dungeon_it->second);
1867 }
1868 if (prefs_.sidebar_active_category.empty() ||
1869 prefs_.sidebar_active_category == "Dungeon") {
1871 }
1872
1874 applied = true;
1875 }
1876
1877 // Revision 24: ActivityBar-only left chrome. Keep the thin category rail, but
1878 // collapse WindowSidebar by default so Tile16 / canvas editors reclaim width.
1879 if (prefs_.panel_layout_defaults_revision < 24 && target_revision >= 24) {
1882 applied = true;
1883 }
1884
1885 // Revision 25: utility and diagnostic dungeon surfaces are hosted by the
1886 // Workbench first. Close only persisted live visibility; explicit pins and
1887 // user-authored named layouts remain untouched.
1888 if (prefs_.panel_layout_defaults_revision < 25 && target_revision >= 25) {
1889 if (auto dungeon_it = prefs_.panel_visibility_state.find("Dungeon");
1890 dungeon_it != prefs_.panel_visibility_state.end()) {
1891 ApplyDungeonPanelAdmissionDefaults(&dungeon_it->second);
1892 }
1894 applied = true;
1895 }
1896
1897 // Revision 26: panel admission demotions (see kRevision26DemotedWindows).
1898 // Only persisted live visibility changes; the windows stay reachable from
1899 // their hosts and the command palette.
1900 if (prefs_.panel_layout_defaults_revision < 26 && target_revision >= 26) {
1901 CloseDemotedWindowVisibility(&prefs_.panel_visibility_state);
1903 applied = true;
1904 }
1905
1906 // Revision 27: overworld area graphics opens in the right column. The
1907 // workspace reset that follows this revision rebuilds the dock so a saved
1908 // bottom strip does not keep covering the maps.
1909 if (prefs_.panel_layout_defaults_revision < 27 && target_revision >= 27) {
1910 if (auto overworld_it = prefs_.panel_visibility_state.find("Overworld");
1911 overworld_it != prefs_.panel_visibility_state.end()) {
1912 overworld_it->second["overworld.area_graphics"] = true;
1913 overworld_it->second["overworld.tile16_selector"] = true;
1914 }
1916 applied = true;
1917 }
1918
1919 // Revision 28: host Objects/Entrance above the room matrix on the right.
1920 // This changes only the pane side; named layouts, pins, and workspace docks
1921 // survive. The user's subsequent side-swap choice remains authoritative.
1922 if (prefs_.panel_layout_defaults_revision < 28 && target_revision >= 28) {
1925 applied = true;
1926 }
1927
1928 // Revision 29: the Workbench owns navigation; an old live visibility
1929 // snapshot must not steal its embedded matrix or entrance selector. Explicit
1930 // named layouts and pins remain available for standalone workflows.
1931 if (prefs_.panel_layout_defaults_revision < 29 && target_revision >= 29) {
1932 auto close_unpinned = [this](const char* category, const char* id) {
1933 auto it = prefs_.panel_visibility_state.find(category);
1934 if (it == prefs_.panel_visibility_state.end())
1935 return;
1936 const auto pin = prefs_.pinned_panels.find(id);
1937 if (pin == prefs_.pinned_panels.end() || !pin->second) {
1938 it->second[id] = false;
1939 }
1940 };
1941 auto dungeon = prefs_.panel_visibility_state.find("Dungeon");
1942 if (dungeon != prefs_.panel_visibility_state.end() &&
1943 dungeon->second["dungeon.workbench"]) {
1944 close_unpinned("Dungeon", "dungeon.room_matrix");
1945 close_unpinned("Dungeon", "dungeon.room_selector");
1946 close_unpinned("Dungeon", "dungeon.entrance_list");
1947 close_unpinned("Dungeon", "dungeon.entrance_properties");
1948 }
1949 close_unpinned("Graphics", "graphics.link_sprite_editor");
1950 close_unpinned("Overworld", "overworld.cutscene_camera");
1952 applied = true;
1953 }
1954
1955 // Revision 30 consolidates ordinary Palette navigation in one workspace.
1956 // Preserve explicit comparison pins and named layouts; migrate only live state.
1957 if (prefs_.panel_layout_defaults_revision < 30 && target_revision >= 30) {
1958 auto palettes = prefs_.panel_visibility_state.find("Palette");
1959 if (palettes != prefs_.panel_visibility_state.end()) {
1960 for (auto& [id, visible] : palettes->second) {
1961 const auto pin = prefs_.pinned_panels.find(id);
1962 if (id != "palette.control_panel" &&
1963 (pin == prefs_.pinned_panels.end() || !pin->second))
1964 visible = false;
1965 }
1966 palettes->second["palette.control_panel"] = true;
1967 }
1969 applied = true;
1970 }
1971
1972 return applied;
1973}
1974
1976 const std::string& side = prefs_.dungeon_inspector_side;
1977 if (side == "left") {
1978 return "left";
1979 }
1980 return "right";
1981}
1982
1984 prefs_.dungeon_inspector_side = (side == "left") ? "left" : "right";
1985}
1986
1988 const WorkspaceOverrides& overrides) {
1989 if (!workspace_baseline_.has_value()) {
1990 workspace_baseline_ = CaptureWorkspaceFields(prefs_);
1991 }
1992 WorkspaceOverrides values = overrides;
1993 // Project files are hand-editable too; keep the font scale usable.
1994 values.font_global_scale = SanitizeFontGlobalScale(values.font_global_scale);
1995 WriteWorkspaceFields(values, &prefs_);
1996}
1997
1999 if (!workspace_baseline_.has_value()) {
2000 return;
2001 }
2002 WriteWorkspaceFields(*workspace_baseline_, &prefs_);
2003 workspace_baseline_.reset();
2004}
2005
2006absl::Status UserSettings::Save() {
2007 try {
2008 // A project overlay stays out of the settings files: write the user's own
2009 // values for the fields it replaced.
2010 std::optional<Preferences> without_overlay;
2011 if (workspace_baseline_.has_value()) {
2012 without_overlay = prefs_;
2013 WriteWorkspaceFields(*workspace_baseline_, &*without_overlay);
2014 }
2015 const Preferences& to_write =
2016 without_overlay.has_value() ? *without_overlay : prefs_;
2017 absl::Status status = absl::OkStatus();
2018#ifdef YAZE_WITH_JSON
2019 status = SavePreferencesToJson(settings_file_path_, to_write);
2020 if (!status.ok()) {
2021 return status;
2022 }
2023#endif
2024 status = SavePreferencesToIni(legacy_settings_file_path_, to_write);
2025 if (!status.ok()) {
2026 return status;
2027 }
2028 } catch (const std::exception& e) {
2029 return absl::InternalError(
2030 absl::StrFormat("Failed to save user settings: %s", e.what()));
2031 }
2032 return absl::OkStatus();
2033}
2034
2035} // namespace editor
2036} // namespace yaze
bool ApplyPanelLayoutDefaultsRevision(int target_revision)
std::string GetDungeonInspectorSide() const
void SetDungeonInspectorSide(std::string side)
void ApplyWorkspaceOverrides(const WorkspaceOverrides &overrides)
std::string legacy_settings_file_path_
std::optional< WorkspaceOverrides > workspace_baseline_
static absl::StatusOr< std::filesystem::path > GetConfigDirectory()
Get the user-specific configuration directory for YAZE.
static absl::StatusOr< std::filesystem::path > GetUserDocumentsSubdirectory(const std::string &subdir)
Get a subdirectory within the user documents folder.
static absl::StatusOr< std::filesystem::path > GetUserDocumentsDirectory()
Get the user's Documents directory.
static absl::Status EnsureDirectoryExists(const std::filesystem::path &path)
Ensure a directory exists, creating it if necessary.
static bool Exists(const std::filesystem::path &path)
Check if a file or directory exists.
static std::filesystem::path GetHomeDirectory()
Get the user's home directory in a cross-platform way.
#define LOG_WARN(category, format,...)
Definition log.h:138
#define LOG_INFO(category, format,...)
Definition log.h:136
void EraseTransientPanelVisibility(std::unordered_map< std::string, bool > *panel_state)
constexpr std::array< const char *, 7 > kDungeonDemotedUtilityPanels
UserSettings::WorkspaceOverrides CaptureWorkspaceFields(const UserSettings::Preferences &prefs)
absl::Status SavePreferencesToIni(const std::filesystem::path &path, const UserSettings::Preferences &prefs)
void ApplyDungeonPanelAdmissionDefaults(std::unordered_map< std::string, bool > *panel_state)
constexpr std::array< const char *, 10 > kDungeonWorkbenchDuplicatePanels
bool IsEmbeddedDungeonUtilityPanelId(const std::string &panel_id)
void WriteWorkspaceFields(const UserSettings::WorkspaceOverrides &values, UserSettings::Preferences *prefs)
absl::Status LoadPreferencesFromIni(const std::filesystem::path &path, UserSettings::Preferences *prefs)
absl::Status EnsureParentDirectory(const std::filesystem::path &path)
bool PruneEmbeddedDungeonUtilityPanelsFromDockTreeJson(std::string *json_body)
void CloseDemotedWindowVisibility(std::unordered_map< std::string, std::unordered_map< std::string, bool > > *visibility_state)
void EraseEmbeddedDungeonUtilityPanelVisibility(std::unordered_map< std::string, bool > *panel_state)
void ApplyDungeonWorkbenchVisibilityDefaults(std::unordered_map< std::string, bool > *panel_state)
nlohmann::json DockTreeToJson(const DockTree &tree)
bool IsTransientPanelVisibilityId(absl::string_view panel_id)
std::string LoadFile(const std::string &filename)
Loads the entire contents of a file into a string.
Definition file_util.cc:63
std::unordered_set< std::string > sidebar_context_collapsed
std::unordered_map< std::string, std::string > panel_shortcuts
std::unordered_map< std::string, std::string > rom_load_choices
std::unordered_map< std::string, std::string > named_layouts
std::unordered_map< std::string, std::unordered_map< std::string, bool > > saved_layouts
std::unordered_map< std::string, float > right_panel_widths
std::unordered_set< std::string > dismissed_feature_hints
std::unordered_map< std::string, std::unordered_map< std::string, bool > > panel_visibility_state
std::unordered_map< std::string, bool > pinned_panels
static std::unique_ptr< DockNode > MakeLeaf(std::vector< PanelEntry > panels)
Definition dock_tree.cc:44
std::unique_ptr< DockNode > root
Definition dock_tree.h:115