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