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