cct-cli 0.7.9.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (325) hide show
  1. calc_terminal/__init__.py +14 -0
  2. calc_terminal/__main__.py +14 -0
  3. calc_terminal/activity.py +1334 -0
  4. calc_terminal/agent.py +3387 -0
  5. calc_terminal/agent_runtime.py +519 -0
  6. calc_terminal/ai_context.py +447 -0
  7. calc_terminal/ai_modes.py +752 -0
  8. calc_terminal/ai_personalization.py +286 -0
  9. calc_terminal/ai_preview_feedback.py +213 -0
  10. calc_terminal/aicore.py +2572 -0
  11. calc_terminal/anim.py +367 -0
  12. calc_terminal/app.py +3685 -0
  13. calc_terminal/art.py +639 -0
  14. calc_terminal/atomsim.py +368 -0
  15. calc_terminal/attachments.py +743 -0
  16. calc_terminal/benchmark_system.py +414 -0
  17. calc_terminal/browser/__init__.py +36 -0
  18. calc_terminal/browser/browser_state.py +346 -0
  19. calc_terminal/browser/devserver.py +176 -0
  20. calc_terminal/browser/engine.py +494 -0
  21. calc_terminal/browser/navigation.py +84 -0
  22. calc_terminal/browser/preview.py +429 -0
  23. calc_terminal/browser/preview_entry.py +95 -0
  24. calc_terminal/browser/project_detector.py +144 -0
  25. calc_terminal/browser/server.py +449 -0
  26. calc_terminal/browser/state.py +75 -0
  27. calc_terminal/browser/watcher.py +99 -0
  28. calc_terminal/browser_gui/__init__.py +1 -0
  29. calc_terminal/browser_gui/__main__.py +3 -0
  30. calc_terminal/browser_gui/launcher.py +173 -0
  31. calc_terminal/browser_gui/playwright_browser.py +117 -0
  32. calc_terminal/browser_gui/qt_browser.py +1501 -0
  33. calc_terminal/browser_gui/webview_browser.py +57 -0
  34. calc_terminal/capabilities/__init__.py +35 -0
  35. calc_terminal/capabilities/adapters/__init__.py +33 -0
  36. calc_terminal/capabilities/adapters/bioinformatics.py +204 -0
  37. calc_terminal/capabilities/adapters/browser_adapter.py +205 -0
  38. calc_terminal/capabilities/adapters/filesystem.py +206 -0
  39. calc_terminal/capabilities/adapters/git_adapter.py +202 -0
  40. calc_terminal/capabilities/adapters/jupyter_adapter.py +138 -0
  41. calc_terminal/capabilities/adapters/ml_frameworks.py +158 -0
  42. calc_terminal/capabilities/adapters/platforms.py +200 -0
  43. calc_terminal/capabilities/adapters/python_exec.py +93 -0
  44. calc_terminal/capabilities/adapters/quantum_adapter.py +150 -0
  45. calc_terminal/capabilities/adapters/scientific_comp.py +123 -0
  46. calc_terminal/capabilities/adapters/structural_bio.py +161 -0
  47. calc_terminal/capabilities/adapters/terminal.py +99 -0
  48. calc_terminal/capabilities/bus.py +178 -0
  49. calc_terminal/capabilities/discovery.py +207 -0
  50. calc_terminal/capabilities/schema.py +221 -0
  51. calc_terminal/cat.ico +0 -0
  52. calc_terminal/cat_browser.py +2018 -0
  53. calc_terminal/chat_store.py +703 -0
  54. calc_terminal/cli.py +1178 -0
  55. calc_terminal/code_editor.py +640 -0
  56. calc_terminal/collaboration.py +723 -0
  57. calc_terminal/commands_data.py +139 -0
  58. calc_terminal/compatibility_engine.py +352 -0
  59. calc_terminal/compute/__init__.py +31 -0
  60. calc_terminal/compute/fabric.py +350 -0
  61. calc_terminal/config.py +227 -0
  62. calc_terminal/core/__init__.py +41 -0
  63. calc_terminal/core/checkpoint.py +156 -0
  64. calc_terminal/core/input/__init__.py +45 -0
  65. calc_terminal/core/mode_registry.py +300 -0
  66. calc_terminal/core/project_graph.py +172 -0
  67. calc_terminal/core/recovery.py +129 -0
  68. calc_terminal/core/security_layer.py +112 -0
  69. calc_terminal/core/task_graph.py +202 -0
  70. calc_terminal/core/unified_runtime.py +184 -0
  71. calc_terminal/core/verification.py +257 -0
  72. calc_terminal/customization.py +1566 -0
  73. calc_terminal/derivations.py +153 -0
  74. calc_terminal/device_control.py +263 -0
  75. calc_terminal/diagnostics/__init__.py +27 -0
  76. calc_terminal/diagnostics/doctor_engine.py +382 -0
  77. calc_terminal/diagnostics/self_test.py +247 -0
  78. calc_terminal/doctor.py +519 -0
  79. calc_terminal/easter_eggs.py +274 -0
  80. calc_terminal/editor/__init__.py +1 -0
  81. calc_terminal/editor/actions.py +263 -0
  82. calc_terminal/editor/commands.py +160 -0
  83. calc_terminal/editor/shortcuts.py +226 -0
  84. calc_terminal/engine.py +259 -0
  85. calc_terminal/errors.py +120 -0
  86. calc_terminal/event_stream.py +146 -0
  87. calc_terminal/eventbus.py +133 -0
  88. calc_terminal/extensions.py +733 -0
  89. calc_terminal/fallback_cli.py +1321 -0
  90. calc_terminal/first_run.py +265 -0
  91. calc_terminal/fomoji_auth.py +1043 -0
  92. calc_terminal/formulas.py +82 -0
  93. calc_terminal/fs_cache.py +121 -0
  94. calc_terminal/fs_watcher.py +277 -0
  95. calc_terminal/game.py +193 -0
  96. calc_terminal/gen1.py +5 -0
  97. calc_terminal/generators.py +245 -0
  98. calc_terminal/gestures/__init__.py +42 -0
  99. calc_terminal/gestures/bindings.py +175 -0
  100. calc_terminal/gestures/manager.py +477 -0
  101. calc_terminal/goodbye.py +363 -0
  102. calc_terminal/gpu3d.py +290 -0
  103. calc_terminal/graphs.py +358 -0
  104. calc_terminal/hardware_analyzer.py +440 -0
  105. calc_terminal/host/__init__.py +30 -0
  106. calc_terminal/host/browser_manager.py +187 -0
  107. calc_terminal/host/desktop.py +1386 -0
  108. calc_terminal/host/launcher.py +395 -0
  109. calc_terminal/host/terminal.py +279 -0
  110. calc_terminal/identity.py +216 -0
  111. calc_terminal/input/__init__.py +54 -0
  112. calc_terminal/input/capabilities.py +258 -0
  113. calc_terminal/input/focus.py +87 -0
  114. calc_terminal/input/gestures.py +64 -0
  115. calc_terminal/input/pointer.py +114 -0
  116. calc_terminal/input/touch.py +345 -0
  117. calc_terminal/keys.py +84 -0
  118. calc_terminal/live_automation.py +165 -0
  119. calc_terminal/mathtext.py +433 -0
  120. calc_terminal/mcp.py +386 -0
  121. calc_terminal/memory.py +337 -0
  122. calc_terminal/memory_v2.py +479 -0
  123. calc_terminal/metrics.py +333 -0
  124. calc_terminal/mode_detection.py +146 -0
  125. calc_terminal/model.py +2431 -0
  126. calc_terminal/model_router.py +665 -0
  127. calc_terminal/models/__init__.py +0 -0
  128. calc_terminal/models/active_state.py +187 -0
  129. calc_terminal/models/dynamic_registry.py +584 -0
  130. calc_terminal/models/manager.py +781 -0
  131. calc_terminal/models/model_metadata.json +3526 -0
  132. calc_terminal/models/profiles.py +194 -0
  133. calc_terminal/models/registry.py +265 -0
  134. calc_terminal/models/schema.py +197 -0
  135. calc_terminal/models/validator.py +287 -0
  136. calc_terminal/models/verification_engine.py +368 -0
  137. calc_terminal/native_picker.py +215 -0
  138. calc_terminal/ollama_catalog.py +279 -0
  139. calc_terminal/ollama_download.py +233 -0
  140. calc_terminal/orchestrator.py +304 -0
  141. calc_terminal/package_research.py +322 -0
  142. calc_terminal/packages.py +1024 -0
  143. calc_terminal/pc_specs.py +116 -0
  144. calc_terminal/permissions.py +334 -0
  145. calc_terminal/pet.py +106 -0
  146. calc_terminal/pipeline.py +505 -0
  147. calc_terminal/platform/__init__.py +491 -0
  148. calc_terminal/platform/desktop.py +491 -0
  149. calc_terminal/platform/web.py +781 -0
  150. calc_terminal/preview/__init__.py +1 -0
  151. calc_terminal/preview/dev_server.py +303 -0
  152. calc_terminal/preview/diagnostics.py +131 -0
  153. calc_terminal/preview/live_reload.py +66 -0
  154. calc_terminal/preview/manager.py +129 -0
  155. calc_terminal/project_stats.py +209 -0
  156. calc_terminal/projects.py +328 -0
  157. calc_terminal/providers/__init__.py +0 -0
  158. calc_terminal/providers/adapters/__init__.py +80 -0
  159. calc_terminal/providers/adapters/anthropic_adapter.py +127 -0
  160. calc_terminal/providers/adapters/base.py +106 -0
  161. calc_terminal/providers/adapters/chinese_adapters.py +420 -0
  162. calc_terminal/providers/adapters/gemini_adapter.py +101 -0
  163. calc_terminal/providers/adapters/ollama_adapter.py +83 -0
  164. calc_terminal/providers/adapters/openai_adapter.py +159 -0
  165. calc_terminal/providers/adapters/other_adapters.py +246 -0
  166. calc_terminal/providers/anthropic_provider.py +172 -0
  167. calc_terminal/providers/auto_update.py +416 -0
  168. calc_terminal/providers/base_provider.py +105 -0
  169. calc_terminal/providers/discovery_manager.py +207 -0
  170. calc_terminal/providers/gemini_provider.py +178 -0
  171. calc_terminal/providers/lifecycle.py +767 -0
  172. calc_terminal/providers/ollama_adapter.py +707 -0
  173. calc_terminal/providers/openai_provider.py +254 -0
  174. calc_terminal/providers/provider_manager.py +1827 -0
  175. calc_terminal/providers/providers.json +4075 -0
  176. calc_terminal/reactionsim.py +279 -0
  177. calc_terminal/registry.py +337 -0
  178. calc_terminal/report.py +162 -0
  179. calc_terminal/research/__init__.py +45 -0
  180. calc_terminal/research/artifact_intel.py +126 -0
  181. calc_terminal/research/data_lineage.py +123 -0
  182. calc_terminal/research/experiment_ledger.py +303 -0
  183. calc_terminal/research/reproducibility.py +131 -0
  184. calc_terminal/resilience/__init__.py +47 -0
  185. calc_terminal/resilience/agent_state.py +121 -0
  186. calc_terminal/resilience/capability_matcher.py +174 -0
  187. calc_terminal/resilience/circuit_breaker.py +158 -0
  188. calc_terminal/resilience/failover_engine.py +230 -0
  189. calc_terminal/resilience/health_monitor.py +192 -0
  190. calc_terminal/resilience/ollama_adapter.py +125 -0
  191. calc_terminal/resilience/orchestrator.py +312 -0
  192. calc_terminal/resilience/types.py +134 -0
  193. calc_terminal/sandbox.py +98 -0
  194. calc_terminal/scires.py +558 -0
  195. calc_terminal/security_scanner.py +126 -0
  196. calc_terminal/session.py +294 -0
  197. calc_terminal/sim3d.py +206 -0
  198. calc_terminal/solver.py +276 -0
  199. calc_terminal/sound.py +127 -0
  200. calc_terminal/task_reports.py +287 -0
  201. calc_terminal/terminal_host.py +201 -0
  202. calc_terminal/terminal_identity.py +411 -0
  203. calc_terminal/test_ai_mode_reliability.py +344 -0
  204. calc_terminal/test_browser.py +368 -0
  205. calc_terminal/test_code_editor_upgrade.py +485 -0
  206. calc_terminal/test_customization.py +1148 -0
  207. calc_terminal/test_customization_ui.py +612 -0
  208. calc_terminal/test_dynamic_registry.py +304 -0
  209. calc_terminal/test_extensions.py +436 -0
  210. calc_terminal/test_overhaul.py +557 -0
  211. calc_terminal/test_project_detect.py +255 -0
  212. calc_terminal/test_root_cause_fix.py +527 -0
  213. calc_terminal/test_stability.py +532 -0
  214. calc_terminal/test_terminal_identity.py +132 -0
  215. calc_terminal/test_v079_speed.py +460 -0
  216. calc_terminal/theme.py +1107 -0
  217. calc_terminal/timeline.py +139 -0
  218. calc_terminal/todos.py +246 -0
  219. calc_terminal/tool_call_normalizer.py +419 -0
  220. calc_terminal/tui.py +104 -0
  221. calc_terminal/ui/__init__.py +8 -0
  222. calc_terminal/ui/activity_panel.py +231 -0
  223. calc_terminal/ui/activity_stream_panel.py +238 -0
  224. calc_terminal/ui/animations.py +122 -0
  225. calc_terminal/ui/app.py +7271 -0
  226. calc_terminal/ui/attach_panel.py +597 -0
  227. calc_terminal/ui/attachments.py +424 -0
  228. calc_terminal/ui/backup_panel.py +810 -0
  229. calc_terminal/ui/browser_shell.py +887 -0
  230. calc_terminal/ui/cat_agent.py +357 -0
  231. calc_terminal/ui/chats_panel.py +899 -0
  232. calc_terminal/ui/command_palette.py +125 -0
  233. calc_terminal/ui/command_palette_modal.py +166 -0
  234. calc_terminal/ui/composer.py +1141 -0
  235. calc_terminal/ui/context_menu.py +197 -0
  236. calc_terminal/ui/conversation.py +1435 -0
  237. calc_terminal/ui/customization_panel.py +1229 -0
  238. calc_terminal/ui/dashboard.py +404 -0
  239. calc_terminal/ui/design_system.py +557 -0
  240. calc_terminal/ui/diff_panel.py +213 -0
  241. calc_terminal/ui/editor.py +2102 -0
  242. calc_terminal/ui/empty_state.py +302 -0
  243. calc_terminal/ui/events.py +487 -0
  244. calc_terminal/ui/extensions_panel.py +815 -0
  245. calc_terminal/ui/footer.py +166 -0
  246. calc_terminal/ui/gestures_panel.py +383 -0
  247. calc_terminal/ui/goodbye_screen.py +100 -0
  248. calc_terminal/ui/header.py +1034 -0
  249. calc_terminal/ui/help_panel.py +254 -0
  250. calc_terminal/ui/live_activities.py +914 -0
  251. calc_terminal/ui/mcp_panel.py +570 -0
  252. calc_terminal/ui/memory_center.py +524 -0
  253. calc_terminal/ui/mode_colors_panel.py +525 -0
  254. calc_terminal/ui/nav_screens.py +747 -0
  255. calc_terminal/ui/ollama_panel.py +536 -0
  256. calc_terminal/ui/palette.py +221 -0
  257. calc_terminal/ui/permission_panel.py +269 -0
  258. calc_terminal/ui/personalization_panel.py +517 -0
  259. calc_terminal/ui/personalize_center.py +1568 -0
  260. calc_terminal/ui/preview_panel.py +441 -0
  261. calc_terminal/ui/resizers.py +402 -0
  262. calc_terminal/ui/sidebar.py +1285 -0
  263. calc_terminal/ui/statusbar.py +168 -0
  264. calc_terminal/ui/theme_css.py +1396 -0
  265. calc_terminal/ui/thinking.py +226 -0
  266. calc_terminal/ui/timeline_panel.py +102 -0
  267. calc_terminal/ui/todo_panel.py +193 -0
  268. calc_terminal/ui/viewport.py +136 -0
  269. calc_terminal/ui/vision_panel.py +489 -0
  270. calc_terminal/ui/welcome_modal.py +343 -0
  271. calc_terminal/ui/widgets.py +160 -0
  272. calc_terminal/ui/workspace.py +831 -0
  273. calc_terminal/viewers/__init__.py +1 -0
  274. calc_terminal/viewers/document_viewer.py +252 -0
  275. calc_terminal/viewers/image_viewer.py +241 -0
  276. calc_terminal/viewers/pdf_viewer.py +203 -0
  277. calc_terminal/viewers/presentation_viewer.py +164 -0
  278. calc_terminal/viewers/registry.py +120 -0
  279. calc_terminal/viewers/spreadsheet_viewer.py +204 -0
  280. calc_terminal/vision/__init__.py +89 -0
  281. calc_terminal/vision/analysis.py +194 -0
  282. calc_terminal/vision/annotations.py +297 -0
  283. calc_terminal/vision/capture.py +171 -0
  284. calc_terminal/vision/context.py +231 -0
  285. calc_terminal/vision/correlation.py +169 -0
  286. calc_terminal/vision/cursor.py +258 -0
  287. calc_terminal/vision/events.py +66 -0
  288. calc_terminal/vision/frame_pipeline.py +259 -0
  289. calc_terminal/vision/priority.py +218 -0
  290. calc_terminal/vision/provider.py +180 -0
  291. calc_terminal/vision/safety.py +149 -0
  292. calc_terminal/vision/session.py +281 -0
  293. calc_terminal/vision/verify.py +162 -0
  294. calc_terminal/vision.py +514 -0
  295. calc_terminal/vscode_integration.py +113 -0
  296. calc_terminal/web/__init__.py +8 -0
  297. calc_terminal/web/cat_runtime.py +710 -0
  298. calc_terminal/web/server.py +2891 -0
  299. calc_terminal/web/static/css/app.css +3152 -0
  300. calc_terminal/web/static/icons/badge-72.png +0 -0
  301. calc_terminal/web/static/icons/cat.ico +0 -0
  302. calc_terminal/web/static/icons/icon-128.png +0 -0
  303. calc_terminal/web/static/icons/icon-144.png +0 -0
  304. calc_terminal/web/static/icons/icon-152.png +0 -0
  305. calc_terminal/web/static/icons/icon-192.png +0 -0
  306. calc_terminal/web/static/icons/icon-384.png +0 -0
  307. calc_terminal/web/static/icons/icon-512.png +0 -0
  308. calc_terminal/web/static/icons/icon-72.png +0 -0
  309. calc_terminal/web/static/icons/icon-96.png +0 -0
  310. calc_terminal/web/static/icons/icon.svg +34 -0
  311. calc_terminal/web/static/icons/new-project.png +0 -0
  312. calc_terminal/web/static/icons/open-project.png +0 -0
  313. calc_terminal/web/static/index.html +734 -0
  314. calc_terminal/web/static/js/app.js +2403 -0
  315. calc_terminal/web/static/manifest.json +88 -0
  316. calc_terminal/web/static/sw.js +230 -0
  317. calc_terminal/workflow_engine.py +769 -0
  318. calc_terminal/workspace.py +593 -0
  319. calc_terminal/workspace_index.py +385 -0
  320. cct_cli-0.7.9.0.dist-info/METADATA +210 -0
  321. cct_cli-0.7.9.0.dist-info/RECORD +325 -0
  322. cct_cli-0.7.9.0.dist-info/WHEEL +5 -0
  323. cct_cli-0.7.9.0.dist-info/entry_points.txt +4 -0
  324. cct_cli-0.7.9.0.dist-info/licenses/LICENSE +21 -0
  325. cct_cli-0.7.9.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1566 @@
1
+ """
2
+ CAT Customization — complete visual & layout customization engine (v0.8.0).
3
+
4
+ Provides:
5
+ CATCustomization
6
+ ├── LayoutManager (positions, sizes, order, docking)
7
+ ├── PanelManager (generic component registry)
8
+ ├── StyleManager (buttons, icons, borders, typography)
9
+ ├── VisibilityManager (show / hide)
10
+ ├── ProfileManager (save / load / rename / delete layouts)
11
+ └── ExtensionUIRegistry (future extensions participate)
12
+
13
+ Single source of truth: one JSON file (~/.cct_customization.json) that
14
+ holds the entire authoritative state. Every UI region reads from the
15
+ same manager; no per-widget duplicate state.
16
+
17
+ Extension lifecycle:
18
+ NOT_INSTALLED -> customization inactive
19
+ INSTALLED_DISABLED -> inactive (CAT uses defaults)
20
+ INSTALLED_ENABLED -> full engine active, live preview enabled
21
+
22
+ Persistence: all changes save immediately and survive restarts.
23
+ Validation: every mutation is validated; on failure the previous valid
24
+ layout is kept and CAT never crashes.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import copy
30
+ import json
31
+ import os
32
+ import threading
33
+ import time
34
+ from dataclasses import dataclass, field, asdict
35
+ from pathlib import Path
36
+ from typing import Dict, List, Optional, Any, Tuple
37
+
38
+ # --------------------------------------------------------------------------
39
+ # Constants & validation rules
40
+ # --------------------------------------------------------------------------
41
+
42
+ VALID_POSITIONS = {"left", "right", "center", "top", "bottom", "floating", "docked", "hidden"}
43
+ VALID_DOCK_STATES = {"docked", "floating", "hidden"}
44
+
45
+ # Generic constraints (cells). Per-component overrides below.
46
+ MIN_WIDTH = 12
47
+ MAX_WIDTH = 200
48
+ MIN_HEIGHT = 1
49
+ MAX_HEIGHT = 120
50
+
51
+ COMPONENT_CONSTRAINTS: Dict[str, Dict[str, Any]] = {
52
+ "sidebar": {"min_width": 14, "max_width": 80, "min_height": 3, "max_height": 60},
53
+ "chat": {"min_width": 24, "max_width": 120, "min_height": 6, "max_height": 80},
54
+ "code_editor": {"min_width": 24, "max_width": 160, "min_height": 6, "max_height": 80},
55
+ "terminal": {"min_width": 20, "max_width": 160, "min_height": 4, "max_height": 40},
56
+ "file_explorer":{"min_width": 14, "max_width": 80, "min_height": 4, "max_height": 60},
57
+ "header": {"min_width": 20, "max_width": 300, "min_height": 1, "max_height": 10},
58
+ "activity_bar": {"min_width": 3, "max_width": 12, "min_height": 3, "max_height": 60},
59
+ "secondary_panel":{"min_width": 14,"max_width": 80, "min_height": 3, "max_height": 60},
60
+ "preview": {"min_width": 20, "max_width": 160, "min_height": 6, "max_height": 80},
61
+ "editor": {"min_width": 24, "max_width": 160, "min_height": 6, "max_height": 80},
62
+ }
63
+
64
+ # Orderable region ids (pane ordering)
65
+ ORDERABLE_IDS = ["sidebar", "chat", "code_editor", "terminal", "file_explorer", "preview", "editor"]
66
+
67
+ # Button style concepts
68
+ VALID_BUTTON_SHAPES = {"minimal", "rounded", "pill", "square", "ghost", "outlined", "filled", "compact", "large", "default"}
69
+ VALID_BUTTON_SIZES = {"small", "medium", "large", "compact", "default"}
70
+ VALID_ICON_SIZES = {"small", "medium", "large", "default"}
71
+ VALID_ALIGNMENTS = {"left", "center", "right", "top", "bottom", "middle", "default"}
72
+
73
+ # --------------------------------------------------------------------------
74
+ # Default configurations
75
+ # --------------------------------------------------------------------------
76
+
77
+ DEFAULT_COMPONENTS: Dict[str, Dict[str, Any]] = {
78
+ "sidebar": {
79
+ "id": "sidebar",
80
+ "type": "panel",
81
+ "position": "left",
82
+ "width": 32,
83
+ "height": None,
84
+ "visible": True,
85
+ "order": 0,
86
+ "dock_state": "docked",
87
+ "style": {},
88
+ "parent": None,
89
+ "constraints": COMPONENT_CONSTRAINTS["sidebar"],
90
+ "alignment": "left",
91
+ "spacing": 1,
92
+ "padding": 1,
93
+ "margin": 0,
94
+ "borders": True,
95
+ "corner_radius": 0,
96
+ },
97
+ "chat": {
98
+ "id": "chat",
99
+ "type": "panel",
100
+ "position": "center",
101
+ "width": None,
102
+ "height": None,
103
+ "visible": True,
104
+ "order": 1,
105
+ "dock_state": "docked",
106
+ "style": {},
107
+ "parent": None,
108
+ "constraints": COMPONENT_CONSTRAINTS["chat"],
109
+ "alignment": "center",
110
+ "spacing": 1,
111
+ "padding": 1,
112
+ "margin": 0,
113
+ "borders": True,
114
+ "corner_radius": 0,
115
+ },
116
+ "code_editor": {
117
+ "id": "code_editor",
118
+ "type": "panel",
119
+ "position": "right",
120
+ "width": 44,
121
+ "height": None,
122
+ "visible": True,
123
+ "order": 2,
124
+ "dock_state": "docked",
125
+ "style": {},
126
+ "parent": None,
127
+ "constraints": COMPONENT_CONSTRAINTS["code_editor"],
128
+ "alignment": "left",
129
+ "spacing": 1,
130
+ "padding": 1,
131
+ "margin": 0,
132
+ "borders": True,
133
+ "corner_radius": 0,
134
+ },
135
+ # Aliases / additional panes
136
+ "editor": {
137
+ "id": "editor",
138
+ "type": "panel",
139
+ "position": "right",
140
+ "width": 44,
141
+ "height": None,
142
+ "visible": True,
143
+ "order": 2,
144
+ "dock_state": "docked",
145
+ "style": {},
146
+ "parent": None,
147
+ "constraints": COMPONENT_CONSTRAINTS["editor"],
148
+ "alignment": "left",
149
+ "spacing": 1,
150
+ "padding": 1,
151
+ "margin": 0,
152
+ "borders": True,
153
+ "corner_radius": 0,
154
+ },
155
+ "terminal": {
156
+ "id": "terminal",
157
+ "type": "panel",
158
+ "position": "bottom",
159
+ "width": None,
160
+ "height": 12,
161
+ "visible": True,
162
+ "order": 3,
163
+ "dock_state": "docked",
164
+ "style": {},
165
+ "parent": None,
166
+ "constraints": COMPONENT_CONSTRAINTS["terminal"],
167
+ "alignment": "left",
168
+ "spacing": 1,
169
+ "padding": 1,
170
+ "margin": 0,
171
+ "borders": True,
172
+ "corner_radius": 0,
173
+ },
174
+ "file_explorer": {
175
+ "id": "file_explorer",
176
+ "type": "panel",
177
+ "position": "left",
178
+ "width": 32,
179
+ "height": None,
180
+ "visible": True,
181
+ "order": 0,
182
+ "dock_state": "docked",
183
+ "style": {},
184
+ "parent": None,
185
+ "constraints": COMPONENT_CONSTRAINTS["file_explorer"],
186
+ "alignment": "left",
187
+ "spacing": 1,
188
+ "padding": 1,
189
+ "margin": 0,
190
+ "borders": True,
191
+ "corner_radius": 0,
192
+ },
193
+ "header": {
194
+ "id": "header",
195
+ "type": "chrome",
196
+ "position": "top",
197
+ "width": None,
198
+ "height": 4,
199
+ "visible": True,
200
+ "order": -1,
201
+ "dock_state": "docked",
202
+ "style": {},
203
+ "parent": None,
204
+ "constraints": COMPONENT_CONSTRAINTS["header"],
205
+ "alignment": "left",
206
+ "spacing": 1,
207
+ "padding": 0,
208
+ "margin": 0,
209
+ "borders": True,
210
+ "corner_radius": 0,
211
+ "logo_position": "left",
212
+ "logo_visible": True,
213
+ "title_position": "center",
214
+ "action_positions": "right",
215
+ },
216
+ "activity_bar": {
217
+ "id": "activity_bar",
218
+ "type": "panel",
219
+ "position": "left",
220
+ "width": 6,
221
+ "height": None,
222
+ "visible": False,
223
+ "order": 0,
224
+ "dock_state": "docked",
225
+ "style": {},
226
+ "parent": None,
227
+ "constraints": COMPONENT_CONSTRAINTS["activity_bar"],
228
+ "alignment": "left",
229
+ "spacing": 1,
230
+ "padding": 0,
231
+ "margin": 0,
232
+ "borders": True,
233
+ "corner_radius": 0,
234
+ },
235
+ "secondary_panel": {
236
+ "id": "secondary_panel",
237
+ "type": "panel",
238
+ "position": "right",
239
+ "width": 32,
240
+ "height": None,
241
+ "visible": False,
242
+ "order": 4,
243
+ "dock_state": "docked",
244
+ "style": {},
245
+ "parent": None,
246
+ "constraints": COMPONENT_CONSTRAINTS["secondary_panel"],
247
+ "alignment": "left",
248
+ "spacing": 1,
249
+ "padding": 1,
250
+ "margin": 0,
251
+ "borders": True,
252
+ "corner_radius": 0,
253
+ },
254
+ "preview": {
255
+ "id": "preview",
256
+ "type": "panel",
257
+ "position": "right",
258
+ "width": 44,
259
+ "height": None,
260
+ "visible": False,
261
+ "order": 3,
262
+ "dock_state": "docked",
263
+ "style": {},
264
+ "parent": None,
265
+ "constraints": COMPONENT_CONSTRAINTS["preview"],
266
+ "alignment": "left",
267
+ "spacing": 1,
268
+ "padding": 1,
269
+ "margin": 0,
270
+ "borders": True,
271
+ "corner_radius": 0,
272
+ },
273
+ }
274
+
275
+ DEFAULT_STYLES: Dict[str, Any] = {
276
+ "buttons": {
277
+ "shape": "rounded",
278
+ "size": "medium",
279
+ "height": None,
280
+ "width": None,
281
+ "border": True,
282
+ "border_radius": 4,
283
+ "padding": 1,
284
+ "spacing": 1,
285
+ "icon_position": "left",
286
+ "text_align": "center",
287
+ "icon_size": "medium",
288
+ "variant": "default",
289
+ },
290
+ "icons": {
291
+ "size": "medium",
292
+ "alignment": "left",
293
+ "spacing": 1,
294
+ "visible": True,
295
+ "position": "left",
296
+ "style": "default",
297
+ "set": "default",
298
+ },
299
+ "borders": {
300
+ "width": 1,
301
+ "style": "solid",
302
+ "radius": 0,
303
+ "color": None,
304
+ },
305
+ "spacing": {"padding": 1, "margin": 0, "gap": 1},
306
+ "typography": {"font_size": "medium", "weight": "normal", "family": "default"},
307
+ "header": {
308
+ "height": 3,
309
+ "logo_position": "left",
310
+ "logo_visible": True,
311
+ "title_position": "left",
312
+ "action_positions": "right",
313
+ "spacing": 1,
314
+ "alignment": "left",
315
+ },
316
+ }
317
+
318
+ DEFAULT_MAIN_MENU: Dict[str, Any] = {
319
+ "order": ["open_folder", "recent_workspaces", "chats", "mcp_servers", "backup_providers", "customize_ai", "themes", "gestures", "extensions", "user", "signout", "customization"],
320
+ "visibility": {},
321
+ "icon_visibility": True,
322
+ "text_visibility": True,
323
+ "spacing": 1,
324
+ "alignment": "left",
325
+ "position": "left",
326
+ }
327
+
328
+ DEFAULT_LAYOUT_PROFILES: Dict[str, Any] = {
329
+ "Development": {
330
+ "layout": {
331
+ "sidebar": {"position": "left", "width": 32, "visible": True, "order": 0},
332
+ "chat": {"position": "center", "visible": True, "order": 1},
333
+ "code_editor": {"position": "right", "width": 44, "visible": True, "order": 2},
334
+ },
335
+ "description": "Code Editor center, Sidebar left, Chat right"
336
+ },
337
+ "Research": {
338
+ "layout": {
339
+ "sidebar": {"position": "right", "width": 28, "visible": True, "order": 2},
340
+ "chat": {"position": "center", "visible": True, "order": 1},
341
+ "code_editor": {"position": "left", "width": 36, "visible": True, "order": 0},
342
+ "terminal": {"position": "bottom", "height": 14, "visible": True},
343
+ },
344
+ "description": "Chat center, Sidebar right, Terminal bottom"
345
+ },
346
+ "Writing": {
347
+ "layout": {
348
+ "sidebar": {"position": "left", "width": 24, "visible": False, "order": 0},
349
+ "chat": {"position": "center", "visible": True, "order": 0},
350
+ "code_editor": {"position": "right", "width": 40, "visible": True, "order": 1},
351
+ },
352
+ "description": "Minimal, focus on Chat and Editor"
353
+ },
354
+ "Minimal": {
355
+ "layout": {
356
+ "sidebar": {"position": "left", "width": 24, "visible": False, "order": 0},
357
+ "chat": {"position": "center", "visible": True, "order": 0},
358
+ "code_editor": {"position": "right", "width": 36, "visible": False, "order": 1},
359
+ "terminal": {"visible": False},
360
+ "activity_bar": {"visible": False},
361
+ "secondary_panel": {"visible": False},
362
+ },
363
+ "description": "Only Chat visible"
364
+ },
365
+ "Full Screen": {
366
+ "layout": {
367
+ "sidebar": {"position": "left", "width": 32, "visible": True, "order": 0},
368
+ "chat": {"position": "center", "visible": True, "order": 1},
369
+ "code_editor": {"position": "right", "width": 54, "visible": True, "order": 2},
370
+ "terminal": {"position": "bottom", "height": 16, "visible": True},
371
+ "header": {"visible": True},
372
+ },
373
+ "description": "All panels visible"
374
+ },
375
+ }
376
+
377
+ CONFIG_PATH = Path.home() / ".cct_customization.json"
378
+ _lock = threading.RLock()
379
+ _cached: Optional[Dict[str, Any]] = None
380
+ _cached_mtime: float = 0
381
+
382
+ # --------------------------------------------------------------------------
383
+ # Helpers
384
+ # --------------------------------------------------------------------------
385
+
386
+ def _deep_copy(obj):
387
+ return copy.deepcopy(obj)
388
+
389
+ def _parse_size_value(v, default=None):
390
+ """Accept int, float, '30%' string, 'custom' -> default, None -> default."""
391
+ if v is None or v == "" or v == "custom" or v == "Custom":
392
+ return default
393
+ if isinstance(v, (int, float)):
394
+ return int(v)
395
+ if isinstance(v, str):
396
+ s = v.strip()
397
+ if s.lower() in ("custom", "auto", "remaining", "remaining space", "1fr"):
398
+ return default
399
+ if s.endswith("%"):
400
+ try:
401
+ pct = float(s[:-1].strip())
402
+ # store as int percent for now, but validation expects cells
403
+ # we keep as int pct; caller can interpret. For validation, treat as width.
404
+ # Convert to cells approx: assume 100 cols terminal, so 30% => 30
405
+ return int(pct)
406
+ except Exception:
407
+ return default
408
+ try:
409
+ return int(float(s))
410
+ except Exception:
411
+ return default
412
+ return default
413
+
414
+ def _valid_component_id(cid: str) -> bool:
415
+ return isinstance(cid, str) and len(cid.strip()) > 0 and len(cid) < 64 and cid.replace("_","").replace("-","").isalnum()
416
+
417
+ # --------------------------------------------------------------------------
418
+ # Validation
419
+ # --------------------------------------------------------------------------
420
+
421
+ def validate_component(comp_id: str, cfg: Dict[str, Any]) -> Tuple[bool, str]:
422
+ """Validate a single component config. Returns (ok, error)."""
423
+ if not _valid_component_id(comp_id):
424
+ return False, f"Invalid component id: {comp_id}"
425
+ pos = cfg.get("position")
426
+ if pos is not None and str(pos).lower() not in VALID_POSITIONS:
427
+ return False, f"Invalid position '{pos}' for {comp_id}"
428
+ dock = cfg.get("dock_state")
429
+ if dock is not None and str(dock).lower() not in VALID_DOCK_STATES:
430
+ return False, f"Invalid dock_state '{dock}' for {comp_id}"
431
+ # width/height
432
+ for key in ("width", "height"):
433
+ v = cfg.get(key)
434
+ if v is None or v == "":
435
+ continue
436
+ # allow preset strings like "auto" -> skip
437
+ if isinstance(v, str) and v.lower() in ("auto", "1fr", "remaining", "remaining space", "custom"):
438
+ continue
439
+ try:
440
+ iv = _parse_size_value(v, None)
441
+ if iv is None:
442
+ continue
443
+ if iv < 0:
444
+ return False, f"Negative {key} {iv} for {comp_id}"
445
+ if iv == 0:
446
+ return False, f"Zero {key} for {comp_id} (must be >=1 or None)"
447
+ # per-component constraints
448
+ constr = COMPONENT_CONSTRAINTS.get(comp_id, {})
449
+ if key == "width":
450
+ lo = constr.get("min_width", MIN_WIDTH)
451
+ hi = constr.get("max_width", MAX_WIDTH)
452
+ else:
453
+ lo = constr.get("min_height", MIN_HEIGHT)
454
+ hi = constr.get("max_height", MAX_HEIGHT)
455
+ if iv < lo:
456
+ # allow smaller than min if hidden? But still warn — we clamp instead of reject if not too tiny
457
+ # For validation, values below absolute MIN are invalid
458
+ if iv < MIN_WIDTH and key == "width":
459
+ return False, f"{key} {iv} too small for {comp_id} (min {lo})"
460
+ if iv < MIN_HEIGHT and key == "height":
461
+ return False, f"{key} {iv} too small for {comp_id} (min {lo})"
462
+ if iv > hi * 2: # allow some oversize but not absurd
463
+ return False, f"{key} {iv} too large for {comp_id} (max {hi})"
464
+ if iv > 500:
465
+ return False, f"{key} {iv} impossible for {comp_id}"
466
+ except Exception as e:
467
+ return False, f"Invalid {key} '{v}' for {comp_id}: {e}"
468
+ # visible must be bool if present
469
+ if "visible" in cfg and not isinstance(cfg["visible"], bool):
470
+ return False, f"visible must be bool for {comp_id}"
471
+ # order
472
+ if "order" in cfg:
473
+ try:
474
+ o = int(cfg["order"])
475
+ if o < -1 or o > 100:
476
+ return False, f"order {o} out of range for {comp_id}"
477
+ except Exception:
478
+ return False, f"Invalid order for {comp_id}"
479
+ # corner_radius, spacing etc must be non-negative
480
+ for k in ("corner_radius", "spacing", "padding", "margin", "border_radius"):
481
+ if k in cfg and cfg[k] is not None:
482
+ try:
483
+ v = int(cfg[k])
484
+ if v < 0:
485
+ return False, f"Negative {k} for {comp_id}"
486
+ if v > 100:
487
+ return False, f"{k} too large for {comp_id}"
488
+ except Exception:
489
+ pass
490
+ # Check for impossible floating without position? Actually floating is valid for many
491
+ # No overlapping critical regions check — ensure at least one of chat/code_editor visible?
492
+ # Not strictly invalid; but warn if all main panes hidden
493
+ return True, ""
494
+
495
+ def validate_layout(layout: Dict[str, Any]) -> Tuple[bool, str]:
496
+ """Validate entire layout dict."""
497
+ if not isinstance(layout, dict):
498
+ return False, "layout must be dict"
499
+ if not layout:
500
+ return False, "layout empty"
501
+ # check each component
502
+ for cid, cfg in layout.items():
503
+ if not isinstance(cfg, dict):
504
+ return False, f"component {cid} must be dict"
505
+ ok, err = validate_component(cid, cfg)
506
+ if not ok:
507
+ return False, err
508
+ # check for inaccessible required controls: at least header or chat or editor visible
509
+ visible_count = sum(1 for c in layout.values() if c.get("visible", True))
510
+ if visible_count == 0:
511
+ return False, "All components hidden — at least one must be visible"
512
+ # check order uniqueness if present
513
+ orders = [c.get("order") for c in layout.values() if "order" in c and isinstance(c.get("order"), int)]
514
+ if len(orders) != len(set(orders)):
515
+ # duplicate order is not fatal but we warn — we auto-fix by reassigning
516
+ # So not invalid, just need dedup
517
+ pass
518
+ # check no overlapping critical floating panels both at same position with same size impossible?
519
+ # For now we consider valid if above passes
520
+ return True, ""
521
+
522
+ def validate_styles(styles: Dict[str, Any]) -> Tuple[bool, str]:
523
+ if not isinstance(styles, dict):
524
+ return False, "styles must be dict"
525
+ # button shape
526
+ buttons = styles.get("buttons", {})
527
+ if isinstance(buttons, dict):
528
+ shape = buttons.get("shape")
529
+ if shape and str(shape).lower() not in VALID_BUTTON_SHAPES:
530
+ # allow custom values — so we don't reject unknown shapes, only warn if absurd
531
+ if len(str(shape)) > 30:
532
+ return False, f"Button shape too long: {shape}"
533
+ size = buttons.get("size")
534
+ if size and str(size).lower() not in VALID_BUTTON_SIZES and len(str(size)) > 20:
535
+ return False, f"Button size invalid: {size}"
536
+ for k in ("border_radius", "padding", "spacing"):
537
+ v = buttons.get(k)
538
+ if v is not None:
539
+ try:
540
+ iv = int(v)
541
+ if iv < 0 or iv > 100:
542
+ return False, f"Button {k} out of range: {v}"
543
+ except Exception:
544
+ return False, f"Invalid button {k}: {v}"
545
+ icons = styles.get("icons", {})
546
+ if isinstance(icons, dict):
547
+ sz = icons.get("size")
548
+ if sz and str(sz).lower() not in VALID_ICON_SIZES and len(str(sz))>20:
549
+ return False, f"Icon size invalid: {sz}"
550
+ return True, ""
551
+
552
+ def validate_config(cfg: Dict[str, Any]) -> Tuple[bool, str]:
553
+ """Validate full customization config."""
554
+ if not isinstance(cfg, dict):
555
+ return False, "config must be dict"
556
+ layout = cfg.get("layout")
557
+ if layout is not None:
558
+ ok, err = validate_layout(layout)
559
+ if not ok:
560
+ return False, f"layout: {err}"
561
+ styles = cfg.get("styles")
562
+ if styles is not None:
563
+ ok, err = validate_styles(styles)
564
+ if not ok:
565
+ return False, f"styles: {err}"
566
+ # main_menu validation
567
+ mm = cfg.get("main_menu")
568
+ if mm is not None and not isinstance(mm, dict):
569
+ return False, "main_menu must be dict"
570
+ return True, ""
571
+
572
+ # --------------------------------------------------------------------------
573
+ # Persistence
574
+ # --------------------------------------------------------------------------
575
+
576
+ def _default_config() -> Dict[str, Any]:
577
+ return {
578
+ "version": 1,
579
+ "layout": _deep_copy(DEFAULT_COMPONENTS),
580
+ "styles": _deep_copy(DEFAULT_STYLES),
581
+ "main_menu": _deep_copy(DEFAULT_MAIN_MENU),
582
+ "header": _deep_copy(DEFAULT_COMPONENTS["header"]),
583
+ "profiles": _deep_copy(DEFAULT_LAYOUT_PROFILES),
584
+ "active_profile": None,
585
+ "updated": time.time(),
586
+ }
587
+
588
+ def _load_raw() -> Dict[str, Any]:
589
+ global _cached, _cached_mtime
590
+ with _lock:
591
+ try:
592
+ mtime = CONFIG_PATH.stat().st_mtime if CONFIG_PATH.exists() else 0
593
+ if _cached is not None and mtime == _cached_mtime and mtime != 0:
594
+ return _deep_copy(_cached)
595
+ except Exception:
596
+ pass
597
+ data = _default_config()
598
+ if CONFIG_PATH.exists():
599
+ try:
600
+ with open(CONFIG_PATH, "r", encoding="utf-8") as f:
601
+ raw = json.load(f)
602
+ if isinstance(raw, dict):
603
+ # version migration
604
+ if "layout" in raw and isinstance(raw["layout"], dict):
605
+ # merge with defaults for missing keys
606
+ merged_layout = _deep_copy(DEFAULT_COMPONENTS)
607
+ for k, v in raw["layout"].items():
608
+ if k in merged_layout and isinstance(v, dict):
609
+ merged_layout[k].update(v)
610
+ else:
611
+ # new / extension component
612
+ merged_layout[k] = v
613
+ data["layout"] = merged_layout
614
+ if "styles" in raw and isinstance(raw["styles"], dict):
615
+ # deep merge
616
+ for k, v in raw["styles"].items():
617
+ if isinstance(v, dict) and k in data["styles"] and isinstance(data["styles"][k], dict):
618
+ data["styles"][k].update(v)
619
+ else:
620
+ data["styles"][k] = v
621
+ if "main_menu" in raw and isinstance(raw["main_menu"], dict):
622
+ data["main_menu"].update(raw["main_menu"])
623
+ if "header" in raw and isinstance(raw["header"], dict):
624
+ if isinstance(data.get("header"), dict):
625
+ data["header"].update(raw["header"])
626
+ else:
627
+ data["header"] = raw["header"]
628
+ if "profiles" in raw and isinstance(raw["profiles"], dict):
629
+ # keep built-ins plus user profiles
630
+ for pk, pv in raw["profiles"].items():
631
+ data["profiles"][pk] = pv
632
+ if "active_profile" in raw:
633
+ data["active_profile"] = raw["active_profile"]
634
+ if "version" in raw:
635
+ data["version"] = raw["version"]
636
+ # also support flat legacy keys?
637
+ except Exception:
638
+ # corrupt file -> keep defaults, but don't crash
639
+ pass
640
+ # validate, fallback to defaults if invalid
641
+ ok, err = validate_config(data)
642
+ if not ok:
643
+ # keep previous valid cached if exists, else defaults
644
+ # For now, fallback to defaults and keep the error in log
645
+ data = _default_config()
646
+ _cached = _deep_copy(data)
647
+ try:
648
+ _cached_mtime = CONFIG_PATH.stat().st_mtime if CONFIG_PATH.exists() else time.time()
649
+ except Exception:
650
+ _cached_mtime = time.time()
651
+ return _deep_copy(data)
652
+
653
+ def _save_raw(data: Dict[str, Any]):
654
+ global _cached, _cached_mtime
655
+ with _lock:
656
+ try:
657
+ ok, err = validate_config(data)
658
+ if not ok:
659
+ # don't save invalid
660
+ return False
661
+ data["updated"] = time.time()
662
+ CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
663
+ with open(CONFIG_PATH, "w", encoding="utf-8") as f:
664
+ json.dump(data, f, indent=2)
665
+ _cached = _deep_copy(data)
666
+ try:
667
+ _cached_mtime = CONFIG_PATH.stat().st_mtime
668
+ except Exception:
669
+ _cached_mtime = time.time()
670
+ return True
671
+ except Exception:
672
+ return False
673
+
674
+ # Public load/save helpers for tests
675
+ def load_config() -> Dict[str, Any]:
676
+ return _load_raw()
677
+
678
+ def save_config(data: Dict[str, Any]) -> bool:
679
+ return _save_raw(data)
680
+
681
+ def is_active() -> bool:
682
+ """Whether customization engine should be active (extension enabled)."""
683
+ try:
684
+ from . import extensions as _ext
685
+ return bool(_ext.is_enabled("customization"))
686
+ except Exception:
687
+ # if extensions module not available or not installed, allow active for tests
688
+ # But spec says when not installed, inactive. For tests without extension registry,
689
+ # we treat as active if config file exists? Better to check file existence?
690
+ # Default to True for testability unless registry says otherwise
691
+ try:
692
+ from .extensions import is_enabled
693
+ return bool(is_enabled("customization"))
694
+ except Exception:
695
+ return True
696
+
697
+ # --------------------------------------------------------------------------
698
+ # Managers
699
+ # --------------------------------------------------------------------------
700
+
701
+ class PanelManager:
702
+ """Generic component registry — future-proof for any new UI region."""
703
+
704
+ def __init__(self, layout_manager: "LayoutManager"):
705
+ self._lm = layout_manager
706
+
707
+ def register_component(self, comp_id: str, meta: Dict[str, Any]) -> bool:
708
+ """Register a new component (from an extension)."""
709
+ if not _valid_component_id(comp_id):
710
+ return False
711
+ cfg = self._lm.get_config()
712
+ if comp_id in cfg["layout"]:
713
+ # already exists — update style/type but don't overwrite position/size arbitrarily
714
+ cfg["layout"][comp_id].update({k: v for k, v in meta.items() if k not in ("position","width","height","order")})
715
+ else:
716
+ base = {
717
+ "id": comp_id,
718
+ "type": meta.get("type", "panel"),
719
+ "position": meta.get("position", "right"),
720
+ "width": meta.get("width", 32),
721
+ "height": meta.get("height", None),
722
+ "visible": meta.get("visible", True),
723
+ "order": len(cfg["layout"]),
724
+ "dock_state": meta.get("dock_state", "docked"),
725
+ "style": meta.get("style", {}),
726
+ "parent": meta.get("parent", None),
727
+ "constraints": meta.get("constraints", {"min_width": MIN_WIDTH, "max_width": MAX_WIDTH}),
728
+ "alignment": meta.get("alignment", "left"),
729
+ "spacing": meta.get("spacing", 1),
730
+ "padding": meta.get("padding", 1),
731
+ "margin": meta.get("margin", 0),
732
+ "borders": meta.get("borders", True),
733
+ "corner_radius": meta.get("corner_radius", 0),
734
+ }
735
+ base.update(meta)
736
+ cfg["layout"][comp_id] = base
737
+ return self._lm._persist(cfg)
738
+
739
+ def unregister_component(self, comp_id: str) -> bool:
740
+ cfg = self._lm.get_config()
741
+ if comp_id in cfg["layout"]:
742
+ # Don't delete core components — just hide them
743
+ if comp_id in DEFAULT_COMPONENTS:
744
+ cfg["layout"][comp_id]["visible"] = False
745
+ else:
746
+ del cfg["layout"][comp_id]
747
+ return self._lm._persist(cfg)
748
+ return False
749
+
750
+ def list_components(self) -> List[Dict[str, Any]]:
751
+ cfg = self._lm.get_config()
752
+ return list(cfg["layout"].values())
753
+
754
+ def get_component(self, comp_id: str) -> Optional[Dict[str, Any]]:
755
+ cfg = self._lm.get_config()
756
+ return _deep_copy(cfg["layout"].get(comp_id))
757
+
758
+ def get_position(self, comp_id: str) -> str:
759
+ comp = self.get_component(comp_id)
760
+ return str(comp.get("position", "left")) if comp else "left"
761
+
762
+ class StyleManager:
763
+ """Buttons, icons, borders, typography, spacing."""
764
+
765
+ def __init__(self, layout_manager: "LayoutManager"):
766
+ self._lm = layout_manager
767
+
768
+ def get_styles(self) -> Dict[str, Any]:
769
+ return _deep_copy(self._lm.get_config().get("styles", {}))
770
+
771
+ def get_button_style(self) -> Dict[str, Any]:
772
+ return _deep_copy(self.get_styles().get("buttons", {}))
773
+
774
+ def set_button_style(self, style_dict: Dict[str, Any] = None, **kwargs) -> Tuple[bool, str]:
775
+ params = dict(style_dict) if isinstance(style_dict, dict) else {}
776
+ params.update(kwargs)
777
+ cfg = self._lm.get_config()
778
+ btn = cfg["styles"].setdefault("buttons", {})
779
+ # validate before apply
780
+ test = _deep_copy(cfg["styles"])
781
+ test["buttons"].update(params)
782
+ ok, err = validate_styles(test)
783
+ if not ok:
784
+ return False, err
785
+ btn.update(params)
786
+ ok2 = self._lm._persist(cfg)
787
+ return (True, "updated") if ok2 else (False, "persist failed")
788
+
789
+ def set_icon_style(self, **kwargs) -> Tuple[bool, str]:
790
+ cfg = self._lm.get_config()
791
+ icons = cfg["styles"].setdefault("icons", {})
792
+ test = _deep_copy(cfg["styles"])
793
+ test["icons"].update(kwargs)
794
+ ok, err = validate_styles(test)
795
+ if not ok:
796
+ return False, err
797
+ icons.update(kwargs)
798
+ ok2 = self._lm._persist(cfg)
799
+ return (True, "updated") if ok2 else (False, "persist failed")
800
+
801
+ def set_header_style(self, **kwargs) -> Tuple[bool, str]:
802
+ cfg = self._lm.get_config()
803
+ hdr = cfg["styles"].setdefault("header", {})
804
+ hdr.update(kwargs)
805
+ # also update header component
806
+ if "header" in cfg["layout"]:
807
+ for k in ("height","logo_position","logo_visible","title_position","action_positions","spacing","alignment"):
808
+ if k in kwargs:
809
+ cfg["layout"]["header"][k] = kwargs[k]
810
+ ok2 = self._lm._persist(cfg)
811
+ return (True, "updated") if ok2 else (False, "persist failed")
812
+
813
+ def set_borders(self, **kwargs) -> Tuple[bool, str]:
814
+ cfg = self._lm.get_config()
815
+ cfg["styles"].setdefault("borders", {}).update(kwargs)
816
+ ok2 = self._lm._persist(cfg)
817
+ return (True, "updated") if ok2 else (False, "persist failed")
818
+
819
+ def set_spacing(self, **kwargs) -> Tuple[bool, str]:
820
+ cfg = self._lm.get_config()
821
+ cfg["styles"].setdefault("spacing", {}).update(kwargs)
822
+ ok2 = self._lm._persist(cfg)
823
+ return (True, "updated") if ok2 else (False, "persist failed")
824
+
825
+ def set_typography(self, **kwargs) -> Tuple[bool, str]:
826
+ cfg = self._lm.get_config()
827
+ cfg["styles"].setdefault("typography", {}).update(kwargs)
828
+ ok2 = self._lm._persist(cfg)
829
+ return (True, "updated") if ok2 else (False, "persist failed")
830
+
831
+ def reset_appearance(self) -> bool:
832
+ cfg = self._lm.get_config()
833
+ cfg["styles"] = _deep_copy(DEFAULT_STYLES)
834
+ return self._lm._persist(cfg)
835
+
836
+ def reset_buttons(self) -> bool:
837
+ cfg = self._lm.get_config()
838
+ cfg["styles"]["buttons"] = _deep_copy(DEFAULT_STYLES["buttons"])
839
+ return self._lm._persist(cfg)
840
+
841
+ class VisibilityManager:
842
+ """Show / hide components."""
843
+
844
+ def __init__(self, layout_manager: "LayoutManager"):
845
+ self._lm = layout_manager
846
+
847
+ def set_visibility(self, comp_id: str, visible: bool) -> Tuple[bool, str]:
848
+ return self._lm.set_visibility(comp_id, visible)
849
+
850
+ def is_visible(self, comp_id: str) -> bool:
851
+ comp = self._lm.get_component(comp_id)
852
+ return bool(comp.get("visible", True)) if comp else False
853
+
854
+ def get_visibility_map(self) -> Dict[str, bool]:
855
+ cfg = self._lm.get_config()
856
+ return {k: bool(v.get("visible", True)) for k, v in cfg["layout"].items()}
857
+
858
+ class ProfileManager:
859
+ """Multiple layout profiles — save / load / rename / delete."""
860
+
861
+ def __init__(self, layout_manager: "LayoutManager"):
862
+ self._lm = layout_manager
863
+
864
+ def list_profiles(self) -> List[str]:
865
+ cfg = self._lm.get_config()
866
+ return list(cfg.get("profiles", {}).keys())
867
+
868
+ def get_profile(self, name: str) -> Optional[Dict[str, Any]]:
869
+ cfg = self._lm.get_config()
870
+ return _deep_copy(cfg.get("profiles", {}).get(name))
871
+
872
+ def save_profile(self, name: str, description: str = "") -> Tuple[bool, str]:
873
+ if not name or not name.strip():
874
+ return False, "Profile name required"
875
+ name = name.strip()
876
+ if len(name) > 64:
877
+ return False, "Name too long"
878
+ cfg = self._lm.get_config()
879
+ snapshot = {
880
+ "layout": _deep_copy(cfg["layout"]),
881
+ "styles": _deep_copy(cfg["styles"]),
882
+ "main_menu": _deep_copy(cfg["main_menu"]),
883
+ "header": _deep_copy(cfg.get("header", {})),
884
+ "description": description or f"Custom layout {name}",
885
+ "saved_at": time.time(),
886
+ }
887
+ cfg["profiles"][name] = snapshot
888
+ ok = self._lm._persist(cfg)
889
+ return (True, f"Profile '{name}' saved") if ok else (False, "persist failed")
890
+
891
+ def load_profile(self, name: str) -> Tuple[bool, str]:
892
+ cfg = self._lm.get_config()
893
+ prof = cfg.get("profiles", {}).get(name)
894
+ if not prof:
895
+ return False, f"Profile '{name}' not found"
896
+ # validate before applying
897
+ test_cfg = _deep_copy(cfg)
898
+ if "layout" in prof:
899
+ test_cfg["layout"] = _deep_copy(prof["layout"])
900
+ if "styles" in prof:
901
+ test_cfg["styles"] = _deep_copy(prof["styles"])
902
+ if "main_menu" in prof:
903
+ test_cfg["main_menu"] = _deep_copy(prof["main_menu"])
904
+ if "header" in prof:
905
+ test_cfg["header"] = _deep_copy(prof["header"])
906
+ ok, err = validate_config(test_cfg)
907
+ if not ok:
908
+ return False, f"Invalid profile: {err}"
909
+ # apply
910
+ if "layout" in prof:
911
+ cfg["layout"] = _deep_copy(prof["layout"])
912
+ if "styles" in prof:
913
+ cfg["styles"] = _deep_copy(prof["styles"])
914
+ if "main_menu" in prof:
915
+ cfg["main_menu"] = _deep_copy(prof["main_menu"])
916
+ if "header" in prof:
917
+ cfg["header"] = _deep_copy(prof["header"])
918
+ cfg["active_profile"] = name
919
+ # also ensure header component in sync
920
+ if "header" in cfg and isinstance(cfg["header"], dict) and "header" in cfg["layout"]:
921
+ for k, v in cfg["header"].items():
922
+ cfg["layout"]["header"][k] = v
923
+ ok2 = self._lm._persist(cfg)
924
+ if ok2:
925
+ # notify listeners? For live preview
926
+ try:
927
+ self._lm._notify_change()
928
+ except Exception:
929
+ pass
930
+ return (True, f"Profile '{name}' loaded") if ok2 else (False, "persist failed")
931
+
932
+ def apply_profile(self, name: str) -> bool:
933
+ ok, _ = self.load_profile(name)
934
+ return ok
935
+
936
+ def delete_profile(self, name: str) -> Tuple[bool, str]:
937
+ cfg = self._lm.get_config()
938
+ if name not in cfg.get("profiles", {}):
939
+ return False, f"Profile '{name}' not found"
940
+ # prevent deleting built-ins? Allow but warn — but we keep built-ins restorable via reset
941
+ # Actually allow delete of any, but if it's built-in default we could keep it; for now allow
942
+ del cfg["profiles"][name]
943
+ if cfg.get("active_profile") == name:
944
+ cfg["active_profile"] = None
945
+ ok = self._lm._persist(cfg)
946
+ return (True, f"Profile '{name}' deleted") if ok else (False, "persist failed")
947
+
948
+ def rename_profile(self, old: str, new: str) -> Tuple[bool, str]:
949
+ if not new or not new.strip():
950
+ return False, "New name required"
951
+ new = new.strip()
952
+ cfg = self._lm.get_config()
953
+ if old not in cfg.get("profiles", {}):
954
+ return False, f"Profile '{old}' not found"
955
+ if new in cfg["profiles"]:
956
+ return False, f"Profile '{new}' already exists"
957
+ cfg["profiles"][new] = cfg["profiles"].pop(old)
958
+ if cfg.get("active_profile") == old:
959
+ cfg["active_profile"] = new
960
+ ok = self._lm._persist(cfg)
961
+ return (True, f"Renamed '{old}' -> '{new}'") if ok else (False, "persist failed")
962
+
963
+ def restore_profile(self, name: str) -> Tuple[bool, str]:
964
+ """Alias for load — for test compatibility."""
965
+ return self.load_profile(name)
966
+
967
+ class ExtensionUIRegistry:
968
+ """Allows other extensions to register their UI components for customization."""
969
+
970
+ def __init__(self, layout_manager: "LayoutManager"):
971
+ self._lm = layout_manager
972
+ self._registry: Dict[str, Dict[str, Any]] = {}
973
+
974
+ def register(self, extension_id: str, component_id: str, meta: Dict[str, Any]) -> bool:
975
+ key = f"{extension_id}:{component_id}"
976
+ self._registry[key] = {"extension": extension_id, "id": component_id, "meta": meta or {}}
977
+ # also register as a panel component so it participates in layout
978
+ try:
979
+ self._lm.panel_manager.register_component(component_id, meta)
980
+ except Exception:
981
+ pass
982
+ return True
983
+
984
+ def unregister(self, extension_id: str, component_id: str):
985
+ key = f"{extension_id}:{component_id}"
986
+ self._registry.pop(key, None)
987
+ try:
988
+ self._lm.panel_manager.unregister_component(component_id)
989
+ except Exception:
990
+ pass
991
+
992
+ def list_registered(self, extension_id: str = None) -> List[Dict[str, Any]]:
993
+ if extension_id:
994
+ return [v for v in self._registry.values() if v["extension"] == extension_id]
995
+ return list(self._registry.values())
996
+
997
+ def is_registered(self, component_id: str) -> bool:
998
+ return any(v["id"] == component_id for v in self._registry.values())
999
+
1000
+ # --------------------------------------------------------------------------
1001
+ # Central Layout Manager
1002
+ # --------------------------------------------------------------------------
1003
+
1004
+ class LayoutManager:
1005
+ """Single source of truth for all layout state."""
1006
+
1007
+ def __init__(self):
1008
+ self._change_listeners: List[Any] = []
1009
+ # managers initialized after self
1010
+ self.panel_manager = PanelManager(self)
1011
+ self.style_manager = StyleManager(self)
1012
+ self.visibility_manager = VisibilityManager(self)
1013
+ self.profile_manager = ProfileManager(self)
1014
+ self.extension_registry = ExtensionUIRegistry(self)
1015
+ # keep previous valid config for fallback
1016
+ self._last_valid: Optional[Dict[str, Any]] = None
1017
+
1018
+ # --- config access ---
1019
+
1020
+ def get_config(self) -> Dict[str, Any]:
1021
+ return _load_raw()
1022
+
1023
+ def _persist(self, cfg: Dict[str, Any]) -> bool:
1024
+ ok, err = validate_config(cfg)
1025
+ if not ok:
1026
+ # restore last valid
1027
+ if self._last_valid is not None:
1028
+ # don't save invalid, keep last valid in memory
1029
+ pass
1030
+ return False
1031
+ # keep backup
1032
+ self._last_valid = _deep_copy(cfg)
1033
+ result = _save_raw(cfg)
1034
+ if result:
1035
+ self._notify_change()
1036
+ return result
1037
+
1038
+ def get_layout(self) -> Dict[str, Any]:
1039
+ cfg = self.get_config()
1040
+ # if inactive, return defaults (CAT core behavior)
1041
+ if not is_active():
1042
+ return _deep_copy(DEFAULT_COMPONENTS)
1043
+ return _deep_copy(cfg["layout"])
1044
+
1045
+ def get_component(self, comp_id: str) -> Optional[Dict[str, Any]]:
1046
+ layout = self.get_layout()
1047
+ return _deep_copy(layout.get(comp_id)) if comp_id in layout else None
1048
+
1049
+ def get_ordered_components(self) -> List[Dict[str, Any]]:
1050
+ layout = self.get_layout()
1051
+ # sort by order
1052
+ items = list(layout.values())
1053
+ items.sort(key=lambda c: c.get("order", 0))
1054
+ return items
1055
+
1056
+ def get_styles(self) -> Dict[str, Any]:
1057
+ cfg = self.get_config()
1058
+ if not is_active():
1059
+ return _deep_copy(DEFAULT_STYLES)
1060
+ return _deep_copy(cfg.get("styles", {}))
1061
+
1062
+ def get_main_menu(self) -> Dict[str, Any]:
1063
+ cfg = self.get_config()
1064
+ if not is_active():
1065
+ return _deep_copy(DEFAULT_MAIN_MENU)
1066
+ return _deep_copy(cfg.get("main_menu", {}))
1067
+
1068
+ # --- mutations ---
1069
+
1070
+ def set_position(self, comp_id: str, position: str) -> Tuple[bool, str]:
1071
+ if not comp_id or not position:
1072
+ return False, "component and position required"
1073
+ comp_id = str(comp_id).strip()
1074
+ position = str(position).strip().lower()
1075
+ if position not in VALID_POSITIONS:
1076
+ return False, f"Invalid position '{position}'"
1077
+ cfg = self.get_config()
1078
+ if comp_id not in cfg["layout"]:
1079
+ # allow generic — create it
1080
+ if not _valid_component_id(comp_id):
1081
+ return False, f"Invalid component id '{comp_id}'"
1082
+ cfg["layout"][comp_id] = _deep_copy(DEFAULT_COMPONENTS.get("sidebar", {}))
1083
+ cfg["layout"][comp_id]["id"] = comp_id
1084
+ # validate
1085
+ test_cfg = _deep_copy(cfg)
1086
+ test_cfg["layout"][comp_id]["position"] = position
1087
+ ok, err = validate_config(test_cfg)
1088
+ if not ok:
1089
+ return False, err
1090
+ cfg["layout"][comp_id]["position"] = position
1091
+ # floating/docked also update dock_state
1092
+ if position == "floating":
1093
+ cfg["layout"][comp_id]["dock_state"] = "floating"
1094
+ elif position == "docked":
1095
+ cfg["layout"][comp_id]["dock_state"] = "docked"
1096
+ _aliases = {"editor": "code_editor", "code_editor": "editor", "sidebar": "file_explorer", "file_explorer": "sidebar"}
1097
+ if comp_id in _aliases and _aliases[comp_id] in cfg["layout"]:
1098
+ alias = _aliases[comp_id]
1099
+ cfg["layout"][alias]["position"] = position
1100
+ if position in ("floating", "docked"):
1101
+ cfg["layout"][alias]["dock_state"] = position
1102
+ if self._persist(cfg):
1103
+ return True, f"{comp_id} -> {position}"
1104
+ return False, "persist failed"
1105
+
1106
+ def set_size(self, comp_id: str, width: Any = None, height: Any = None) -> Tuple[bool, str]:
1107
+ if not comp_id:
1108
+ return False, "component required"
1109
+ comp_id = str(comp_id).strip()
1110
+ cfg = self.get_config()
1111
+ if comp_id not in cfg["layout"]:
1112
+ return False, f"Component '{comp_id}' not found"
1113
+ test_cfg = _deep_copy(cfg)
1114
+ if width is not None:
1115
+ # allow None to mean auto
1116
+ parsed = _parse_size_value(width, None) if width not in (None, "", "auto", "1fr") else None
1117
+ # for string "auto" keep None
1118
+ if isinstance(width, str) and width.lower() in ("auto","1fr","remaining"):
1119
+ test_cfg["layout"][comp_id]["width"] = None
1120
+ else:
1121
+ if parsed is not None and parsed < 0:
1122
+ return False, "Negative width"
1123
+ if parsed is not None and parsed == 0:
1124
+ return False, "Zero width invalid"
1125
+ test_cfg["layout"][comp_id]["width"] = parsed
1126
+ if height is not None:
1127
+ if isinstance(height, str) and height.lower() in ("auto","1fr","remaining"):
1128
+ test_cfg["layout"][comp_id]["height"] = None
1129
+ else:
1130
+ parsed = _parse_size_value(height, None)
1131
+ if parsed is not None and parsed < 0:
1132
+ return False, "Negative height"
1133
+ if parsed is not None and parsed == 0:
1134
+ return False, "Zero height invalid"
1135
+ test_cfg["layout"][comp_id]["height"] = parsed
1136
+ ok, err = validate_config(test_cfg)
1137
+ if not ok:
1138
+ return False, err
1139
+ # apply
1140
+ if width is not None:
1141
+ if isinstance(width, str) and width.lower() in ("auto","1fr","remaining"):
1142
+ cfg["layout"][comp_id]["width"] = None
1143
+ else:
1144
+ cfg["layout"][comp_id]["width"] = _parse_size_value(width, None)
1145
+ if height is not None:
1146
+ if isinstance(height, str) and height.lower() in ("auto","1fr","remaining"):
1147
+ cfg["layout"][comp_id]["height"] = None
1148
+ else:
1149
+ cfg["layout"][comp_id]["height"] = _parse_size_value(height, None)
1150
+ _aliases = {"editor": "code_editor", "code_editor": "editor", "sidebar": "file_explorer", "file_explorer": "sidebar"}
1151
+ if comp_id in _aliases and _aliases[comp_id] in cfg["layout"]:
1152
+ alias = _aliases[comp_id]
1153
+ if width is not None:
1154
+ cfg["layout"][alias]["width"] = cfg["layout"][comp_id]["width"]
1155
+ if height is not None:
1156
+ cfg["layout"][alias]["height"] = cfg["layout"][comp_id]["height"]
1157
+ if self._persist(cfg):
1158
+ return True, f"{comp_id} size updated"
1159
+ return False, "persist failed"
1160
+
1161
+ def set_visibility(self, comp_id: str, visible: bool) -> Tuple[bool, str]:
1162
+ if not comp_id:
1163
+ return False, "component required"
1164
+ comp_id = str(comp_id).strip()
1165
+ cfg = self.get_config()
1166
+ if comp_id not in cfg["layout"]:
1167
+ return False, f"Component '{comp_id}' not found"
1168
+ test_cfg = _deep_copy(cfg)
1169
+ test_cfg["layout"][comp_id]["visible"] = bool(visible)
1170
+ ok, err = validate_config(test_cfg)
1171
+ if not ok:
1172
+ return False, err
1173
+ cfg["layout"][comp_id]["visible"] = bool(visible)
1174
+ _aliases = {"editor": "code_editor", "code_editor": "editor", "sidebar": "file_explorer", "file_explorer": "sidebar"}
1175
+ if comp_id in _aliases and _aliases[comp_id] in cfg["layout"]:
1176
+ cfg["layout"][_aliases[comp_id]]["visible"] = bool(visible)
1177
+ if self._persist(cfg):
1178
+ return True, f"{comp_id} visibility -> {visible}"
1179
+ return False, "persist failed"
1180
+
1181
+ def set_order(self, ordered_ids: List[str]) -> Tuple[bool, str]:
1182
+ if not isinstance(ordered_ids, (list, tuple)):
1183
+ return False, "order must be list"
1184
+ if not ordered_ids:
1185
+ return False, "order empty"
1186
+ # validate all ids exist (or create? but require exist)
1187
+ cfg = self.get_config()
1188
+ layout = cfg["layout"]
1189
+ for cid in ordered_ids:
1190
+ if cid not in layout:
1191
+ return False, f"Unknown component '{cid}'"
1192
+ # check duplicates
1193
+ if len(ordered_ids) != len(set(ordered_ids)):
1194
+ return False, "Duplicate ids in order"
1195
+ test_cfg = _deep_copy(cfg)
1196
+ for idx, cid in enumerate(ordered_ids):
1197
+ test_cfg["layout"][cid]["order"] = idx
1198
+ # remaining not in list keep after
1199
+ remaining = [k for k in layout if k not in ordered_ids]
1200
+ remaining.sort(key=lambda k: layout[k].get("order", 99))
1201
+ for cid in remaining:
1202
+ test_cfg["layout"][cid]["order"] = len(ordered_ids) + remaining.index(cid)
1203
+ ok, err = validate_config(test_cfg)
1204
+ if not ok:
1205
+ return False, err
1206
+ # apply
1207
+ for idx, cid in enumerate(ordered_ids):
1208
+ cfg["layout"][cid]["order"] = idx
1209
+ for cid in remaining:
1210
+ cfg["layout"][cid]["order"] = len(ordered_ids) + remaining.index(cid)
1211
+ if self._persist(cfg):
1212
+ return True, "order updated"
1213
+ return False, "persist failed"
1214
+
1215
+ def reorder(self, ordered_ids: List[str]) -> Tuple[bool, str]:
1216
+ return self.set_order(ordered_ids)
1217
+
1218
+ def set_dock_state(self, comp_id: str, state: str) -> Tuple[bool, str]:
1219
+ if state not in VALID_DOCK_STATES:
1220
+ return False, f"Invalid dock_state '{state}'"
1221
+ cfg = self.get_config()
1222
+ if comp_id not in cfg["layout"]:
1223
+ return False, f"Component '{comp_id}' not found"
1224
+ cfg["layout"][comp_id]["dock_state"] = state
1225
+ if state == "floating":
1226
+ cfg["layout"][comp_id]["position"] = "floating"
1227
+ elif state == "docked" and cfg["layout"][comp_id].get("position") == "floating":
1228
+ cfg["layout"][comp_id]["position"] = "right"
1229
+ if self._persist(cfg):
1230
+ return True, f"{comp_id} dock -> {state}"
1231
+ return False, "persist failed"
1232
+
1233
+ def set_header_config(self, config_dict: Dict[str, Any] = None, **kwargs) -> Tuple[bool, str]:
1234
+ params = dict(config_dict) if isinstance(config_dict, dict) else {}
1235
+ params.update(kwargs)
1236
+ cfg = self.get_config()
1237
+ hdr = cfg["layout"].get("header", {})
1238
+ # validate header height
1239
+ if "height" in params:
1240
+ try:
1241
+ h = int(params["height"])
1242
+ if h < 1 or h > 10:
1243
+ return False, f"Header height {h} out of range"
1244
+ except Exception:
1245
+ return False, "Invalid header height"
1246
+ hdr.update(params)
1247
+ # also sync to styles header
1248
+ cfg["styles"].setdefault("header", {}).update(params)
1249
+ cfg["layout"]["header"] = hdr
1250
+ cfg["header"] = _deep_copy(hdr)
1251
+ if self._persist(cfg):
1252
+ return True, "header updated"
1253
+ return False, "persist failed"
1254
+
1255
+ def set_header(self, config_dict: Dict[str, Any] = None, **kwargs) -> Tuple[bool, str]:
1256
+ return self.set_header_config(config_dict, **kwargs)
1257
+
1258
+ def set_main_menu_config(self, order: Any = None, visibility: Dict[str, bool] = None, **kwargs) -> Tuple[bool, str]:
1259
+ cfg = self.get_config()
1260
+ mm = cfg.setdefault("main_menu", _deep_copy(DEFAULT_MAIN_MENU))
1261
+ if isinstance(order, dict) and visibility is None and not kwargs:
1262
+ if "order" in order and isinstance(order["order"], (list, tuple)):
1263
+ mm["order"] = list(order["order"])
1264
+ if "visibility" in order and isinstance(order["visibility"], dict):
1265
+ mm["visibility"].update(order["visibility"])
1266
+ for k, v in order.items():
1267
+ if k not in ("order", "visibility"):
1268
+ mm[k] = v
1269
+ else:
1270
+ if order is not None:
1271
+ if not isinstance(order, (list, tuple)):
1272
+ return False, "order must be list"
1273
+ mm["order"] = list(order)
1274
+ if visibility is not None:
1275
+ if not isinstance(visibility, dict):
1276
+ return False, "visibility must be dict"
1277
+ mm["visibility"].update(visibility)
1278
+ for k, v in kwargs.items():
1279
+ mm[k] = v
1280
+ if self._persist(cfg):
1281
+ return True, "main_menu updated"
1282
+ return False, "persist failed"
1283
+
1284
+ def set_main_menu(self, mm_dict: Dict[str, Any] = None, **kwargs) -> Tuple[bool, str]:
1285
+ return self.set_main_menu_config(mm_dict, **kwargs)
1286
+
1287
+ def set_style(self, category: str, **kwargs) -> Tuple[bool, str]:
1288
+ cfg = self.get_config()
1289
+ styles = cfg.setdefault("styles", {})
1290
+ cat = styles.setdefault(category, {})
1291
+ test = _deep_copy(styles)
1292
+ test.setdefault(category, {}).update(kwargs)
1293
+ # wrap
1294
+ test_cfg = _deep_copy(cfg)
1295
+ test_cfg["styles"] = test
1296
+ ok, err = validate_config(test_cfg)
1297
+ if not ok:
1298
+ return False, err
1299
+ cat.update(kwargs)
1300
+ if self._persist(cfg):
1301
+ return True, f"{category} style updated"
1302
+ return False, "persist failed"
1303
+
1304
+ # --- validation & fallback ---
1305
+
1306
+ def validate(self) -> Tuple[bool, str]:
1307
+ cfg = self.get_config()
1308
+ return validate_config(cfg)
1309
+
1310
+ def try_apply(self, new_cfg: Dict[str, Any]) -> Tuple[bool, str]:
1311
+ """Try to apply new config; on failure keep previous valid."""
1312
+ ok, err = validate_config(new_cfg)
1313
+ if not ok:
1314
+ return False, err
1315
+ if self._persist(new_cfg):
1316
+ return True, "applied"
1317
+ return False, "persist failed"
1318
+
1319
+ def get_visibility_map(self) -> Dict[str, bool]:
1320
+ cfg = self.get_config()
1321
+ return {k: bool(v.get("visible", True)) for k, v in cfg["layout"].items()}
1322
+
1323
+ # --- reset controls ---
1324
+
1325
+ def reset_current_layout(self) -> Tuple[bool, str]:
1326
+ cfg = self.get_config()
1327
+ cfg["layout"] = _deep_copy(DEFAULT_COMPONENTS)
1328
+ cfg["active_profile"] = None
1329
+ if self._persist(cfg):
1330
+ return True, "Layout reset to defaults"
1331
+ return False, "persist failed"
1332
+
1333
+ def reset_appearance(self) -> Tuple[bool, str]:
1334
+ cfg = self.get_config()
1335
+ cfg["styles"] = _deep_copy(DEFAULT_STYLES)
1336
+ if self._persist(cfg):
1337
+ return True, "Appearance reset"
1338
+ return False, "persist failed"
1339
+
1340
+ def reset_buttons(self) -> Tuple[bool, str]:
1341
+ cfg = self.get_config()
1342
+ cfg["styles"]["buttons"] = _deep_copy(DEFAULT_STYLES["buttons"])
1343
+ if self._persist(cfg):
1344
+ return True, "Buttons reset"
1345
+ return False, "persist failed"
1346
+
1347
+ def reset_pane_positions(self) -> Tuple[bool, str]:
1348
+ cfg = self.get_config()
1349
+ for cid, def_cfg in DEFAULT_COMPONENTS.items():
1350
+ if cid in cfg["layout"]:
1351
+ cfg["layout"][cid]["position"] = def_cfg["position"]
1352
+ cfg["layout"][cid]["width"] = def_cfg["width"]
1353
+ cfg["layout"][cid]["height"] = def_cfg["height"]
1354
+ cfg["layout"][cid]["order"] = def_cfg["order"]
1355
+ cfg["layout"][cid]["dock_state"] = def_cfg["dock_state"]
1356
+ cfg["layout"][cid]["visible"] = def_cfg["visible"]
1357
+ if self._persist(cfg):
1358
+ return True, "Pane positions reset"
1359
+ return False, "persist failed"
1360
+
1361
+ def reset_everything(self) -> Tuple[bool, str]:
1362
+ new_cfg = _default_config()
1363
+ # preserve profiles? spec says Reset Everything resets everything, but preserve where practical?
1364
+ # For Reset Everything, we reset layout/styles but keep profiles structure? We'll reset to defaults
1365
+ # and keep no profiles except defaults? Actually spec: Reset Everything should reset all customization.
1366
+ # We'll keep built-in profiles but clear custom ones? Safer to reset to full defaults.
1367
+ new_cfg = _default_config()
1368
+ if self._persist(new_cfg):
1369
+ return True, "Everything reset"
1370
+ return False, "persist failed"
1371
+
1372
+ def apply_profile(self, name: str) -> bool:
1373
+ ok, _ = self.profile_manager.load_profile(name)
1374
+ return ok
1375
+
1376
+ def save_profile(self, name: str, description: str = "") -> Tuple[bool, str]:
1377
+ return self.profile_manager.save_profile(name, description)
1378
+
1379
+ def delete_profile(self, name: str) -> Tuple[bool, str]:
1380
+ return self.profile_manager.delete_profile(name)
1381
+
1382
+ def list_profiles(self) -> List[str]:
1383
+ return self.profile_manager.list_profiles()
1384
+
1385
+ def set_button_style(self, style_dict: Dict[str, Any] = None, **kwargs) -> Tuple[bool, str]:
1386
+ return self.style_manager.set_button_style(style_dict, **kwargs)
1387
+
1388
+ def get_button_style(self) -> Dict[str, Any]:
1389
+ return self.style_manager.get_button_style()
1390
+
1391
+ def reset_all(self) -> Tuple[bool, str]:
1392
+ return self.reset_everything()
1393
+
1394
+ def reset_button_styles(self) -> Tuple[bool, str]:
1395
+ return self.reset_buttons()
1396
+
1397
+ def reset_layout(self) -> Tuple[bool, str]:
1398
+ return self.reset_current_layout()
1399
+
1400
+ def reset_styles(self) -> Tuple[bool, str]:
1401
+ return self.reset_appearance()
1402
+
1403
+ def get_position(self, comp_id: str) -> str:
1404
+ return self.panel_manager.get_position(comp_id)
1405
+
1406
+ def get_visibility(self, comp_id: str) -> bool:
1407
+ return self.visibility_manager.is_visible(comp_id)
1408
+
1409
+ # --- responsive & layout building ---
1410
+
1411
+ def compute_layout_for_width(self, total_width: int) -> Dict[str, Any]:
1412
+ """Responsive helper: clamp sizes to terminal width, hide optional if needed."""
1413
+ layout = self.get_layout()
1414
+ # never allow overlapping or off-screen
1415
+ # Ensure visible components fit within total_width with min sizes
1416
+ visible = [c for c in layout.values() if c.get("visible", True) and c.get("position") in ("left","right","center")]
1417
+ if not visible:
1418
+ return layout
1419
+ # if sum of fixed widths > total_width, shrink proportionally but respect mins
1420
+ fixed_widths = []
1421
+ for c in visible:
1422
+ w = c.get("width")
1423
+ if isinstance(w, int) and w:
1424
+ fixed_widths.append((c["id"], w))
1425
+ total_fixed = sum(w for _, w in fixed_widths)
1426
+ if total_fixed and total_fixed > total_width - 20: # keep at least 20 for chat
1427
+ # scale down
1428
+ scale = (total_width - 20) / max(1, total_fixed)
1429
+ for cid, w in fixed_widths:
1430
+ new_w = max(COMPONENT_CONSTRAINTS.get(cid, {}).get("min_width", MIN_WIDTH), int(w * scale))
1431
+ layout[cid]["width"] = new_w
1432
+ return layout
1433
+
1434
+ def build_render_plan(self) -> List[Dict[str, Any]]:
1435
+ """Return ordered list for rendering — sorted by order, with dock handling."""
1436
+ ordered = self.get_ordered_components()
1437
+ # filter hidden? Should still return but marked hidden? For now return visible only
1438
+ # But spec says hidden panes must release allocated space — so we skip hidden in plan
1439
+ plan = []
1440
+ for c in ordered:
1441
+ if c.get("visible", True):
1442
+ plan.append(c)
1443
+ return plan
1444
+
1445
+ # --- listeners ---
1446
+
1447
+ def add_change_listener(self, cb):
1448
+ self._change_listeners.append(cb)
1449
+
1450
+ def remove_change_listener(self, cb):
1451
+ if cb in self._change_listeners:
1452
+ self._change_listeners.remove(cb)
1453
+
1454
+ def _notify_change(self):
1455
+ for cb in list(self._change_listeners):
1456
+ try:
1457
+ cb(self.get_config())
1458
+ except Exception:
1459
+ pass
1460
+
1461
+ # --- persistence helpers for external tests ---
1462
+
1463
+ def _force_save(self):
1464
+ _save_raw(self.get_config())
1465
+
1466
+ # --------------------------------------------------------------------------
1467
+ # CATCustomization facade (spec section 26)
1468
+ # --------------------------------------------------------------------------
1469
+
1470
+ class CATCustomization:
1471
+ """Reusable layout configuration system — facade over all managers."""
1472
+
1473
+ def __init__(self):
1474
+ self.layout_manager = LayoutManager()
1475
+ self.panel_manager = self.layout_manager.panel_manager
1476
+ self.style_manager = self.layout_manager.style_manager
1477
+ self.visibility_manager = self.layout_manager.visibility_manager
1478
+ self.profile_manager = self.layout_manager.profile_manager
1479
+ self.extension_registry = self.layout_manager.extension_registry
1480
+
1481
+ # delegates
1482
+ def get_layout(self): return self.layout_manager.get_layout()
1483
+ def is_active(self): return is_active()
1484
+
1485
+ # Singleton
1486
+ _singleton: Optional[CATCustomization] = None
1487
+ _singleton_lock = threading.RLock()
1488
+
1489
+ def get_customization() -> CATCustomization:
1490
+ global _singleton
1491
+ with _singleton_lock:
1492
+ if _singleton is None:
1493
+ _singleton = CATCustomization()
1494
+ return _singleton
1495
+
1496
+ def get_manager() -> LayoutManager:
1497
+ return get_customization().layout_manager
1498
+
1499
+ # Convenience module-level functions for tests / UI
1500
+ def get_layout(): return get_manager().get_layout()
1501
+ def set_position(cid, pos): return get_manager().set_position(cid, pos)
1502
+ def set_size(cid, width=None, height=None): return get_manager().set_size(cid, width, height)
1503
+ def set_visibility(cid, visible): return get_manager().set_visibility(cid, visible)
1504
+ def reorder(order): return get_manager().set_order(order)
1505
+ def validate(): return get_manager().validate()
1506
+ def list_profiles(): return get_manager().profile_manager.list_profiles()
1507
+ def save_profile(name, desc=""): return get_manager().profile_manager.save_profile(name, desc)
1508
+ def load_profile(name): return get_manager().profile_manager.load_profile(name)
1509
+ def delete_profile(name): return get_manager().profile_manager.delete_profile(name)
1510
+ def rename_profile(old, new): return get_manager().profile_manager.rename_profile(old, new)
1511
+
1512
+ def reset_current_layout(): return get_manager().reset_current_layout()
1513
+ def reset_appearance(): return get_manager().reset_appearance()
1514
+ def reset_buttons(): return get_manager().reset_buttons()
1515
+ def reset_pane_positions(): return get_manager().reset_pane_positions()
1516
+ def reset_everything(): return get_manager().reset_everything()
1517
+
1518
+ # For extension lifecycle
1519
+ def on_install():
1520
+ # ensure config exists
1521
+ _load_raw()
1522
+ return True
1523
+
1524
+ def on_enable():
1525
+ # nothing special — is_active will now return True
1526
+ try:
1527
+ lm = get_manager()
1528
+ lm._notify_change()
1529
+ except Exception:
1530
+ pass
1531
+ return True
1532
+
1533
+ def on_disable():
1534
+ # restore safe default layout for CAT core behavior
1535
+ # But preserve user config on disk for reinstall
1536
+ try:
1537
+ lm = get_manager()
1538
+ lm._notify_change()
1539
+ except Exception:
1540
+ pass
1541
+ return True
1542
+
1543
+ def on_uninstall():
1544
+ # preserve customization profile on disk for reinstall, but disable engine
1545
+ # We do NOT delete CONFIG_PATH; just notify
1546
+ try:
1547
+ lm = get_manager()
1548
+ lm._notify_change()
1549
+ except Exception:
1550
+ pass
1551
+ return True
1552
+
1553
+ # For tests — clear file
1554
+ def _reset_for_tests():
1555
+ try:
1556
+ if CONFIG_PATH.exists():
1557
+ CONFIG_PATH.unlink()
1558
+ except Exception:
1559
+ pass
1560
+ global _cached, _cached_mtime, _singleton
1561
+ _cached = None
1562
+ _cached_mtime = 0
1563
+ _singleton = None
1564
+
1565
+ def _clear_all_for_test():
1566
+ _reset_for_tests()