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