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,1501 @@
1
+ """
2
+ CAT Browser — Qt WebEngine (Chromium) implementation.
3
+ Real HTML/CSS/JS rendering via QWebEngineView (WebView2/Chromium).
4
+
5
+ This is the primary graphical backend. It provides:
6
+ - Tab bar (favicon + title + close, + new tab, scrollable)
7
+ - Navigation toolbar (Back/Forward/Reload/Home, lock, address/search, star, menu)
8
+ - Animated progress bar during page loads
9
+ - Web viewport (QWebEngineView per tab, persistent profile for cookies/storage)
10
+ - New-tab page (CAT branded, centered search, shortcuts, recent)
11
+ - History/Bookmarks/Downloads as graphical dialogs (not terminal text)
12
+ - Menu, fullscreen, shortcuts, responsive layout, theme integration
13
+ - Localhost/Fomoji URLs hidden from address bar for security
14
+
15
+ Uses PySide6 if available, else PyQt6 (same API).
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import os
21
+ import sys
22
+ import re
23
+ import json
24
+ import time
25
+ import urllib.parse
26
+ from pathlib import Path
27
+
28
+ _QT_BINDING = None
29
+ try:
30
+ from PySide6.QtWidgets import (
31
+ QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
32
+ QTabWidget, QToolBar, QLineEdit, QPushButton, QLabel, QStackedWidget,
33
+ QDialog, QListWidget, QListWidgetItem, QDialogButtonBox, QMessageBox,
34
+ QProgressBar, QFrame, QSizePolicy,
35
+ )
36
+ from PySide6.QtCore import Qt, QUrl, Signal, QSize, QTimer, QPropertyAnimation, QEasingCurve
37
+ from PySide6.QtGui import QAction, QIcon, QKeySequence, QShortcut, QColor, QPalette, QFont
38
+ from PySide6.QtWebEngineWidgets import QWebEngineView
39
+ from PySide6.QtWebEngineCore import QWebEngineProfile, QWebEnginePage, QWebEngineDownloadRequest
40
+ _QT_BINDING = "PySide6"
41
+ except ImportError:
42
+ try:
43
+ from PyQt6.QtWidgets import (
44
+ QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
45
+ QTabWidget, QToolBar, QLineEdit, QPushButton, QLabel, QStackedWidget,
46
+ QDialog, QListWidget, QListWidgetItem, QDialogButtonBox, QMessageBox,
47
+ QProgressBar, QFrame, QSizePolicy,
48
+ )
49
+ from PyQt6.QtCore import Qt, QUrl, pyqtSignal as Signal, QSize, QTimer, QPropertyAnimation, QEasingCurve
50
+ from PyQt6.QtGui import QAction, QIcon, QKeySequence, QShortcut, QColor, QPalette, QFont
51
+ from PyQt6.QtWebEngineWidgets import QWebEngineView
52
+ from PyQt6.QtWebEngineCore import QWebEngineProfile, QWebEnginePage, QWebEngineDownloadRequest
53
+ _QT_BINDING = "PyQt6"
54
+ except ImportError:
55
+ _QT_BINDING = None
56
+
57
+ if _QT_BINDING is None:
58
+ raise ImportError("No Qt binding found (need PySide6 or PyQt6 with WebEngine)")
59
+
60
+ try:
61
+ from ..browser.browser_state import BrowserState
62
+ except ImportError:
63
+ from calc_terminal.browser.browser_state import BrowserState # type: ignore
64
+
65
+ _URL_RE = re.compile(r"^[a-zA-Z][a-zA-Z0-9+.-]*://")
66
+
67
+ _FOMOJI_PAGE_NAMES = {
68
+ "connector.html": "Fomoji Connect",
69
+ "home.html": "Fomoji Home",
70
+ "security.html": "Fomoji Security",
71
+ "identities.html": "Fomoji Identities",
72
+ "settings.html": "Fomoji Settings",
73
+ }
74
+
75
+ _FOMOJI_LOADING_STEPS = [
76
+ (0, "Initializing"),
77
+ (15, "Finding server"),
78
+ (30, "Starting Node.js"),
79
+ (50, "Waiting for server"),
80
+ (70, "Checking connection"),
81
+ (85, "Almost ready"),
82
+ (100, "Connected"),
83
+ ]
84
+
85
+ def _blend_hex(c1: str, c2: str, factor: float) -> str:
86
+ try:
87
+ c1 = c1.lstrip("#")
88
+ c2 = c2.lstrip("#")
89
+ if len(c1) == 3:
90
+ c1 = "".join(ch * 2 for ch in c1)
91
+ if len(c2) == 3:
92
+ c2 = "".join(ch * 2 for ch in c2)
93
+ r1, g1, b1 = int(c1[0:2], 16), int(c1[2:4], 16), int(c1[4:6], 16)
94
+ r2, g2, b2 = int(c2[0:2], 16), int(c2[2:4], 16), int(c2[4:6], 16)
95
+ r = int(r1 + (r2 - r1) * factor)
96
+ g = int(g1 + (g2 - g1) * factor)
97
+ b = int(b1 + (b2 - b1) * factor)
98
+ return f"#{max(0, min(255, r)):02x}{max(0, min(255, g)):02x}{max(0, min(255, b)):02x}"
99
+ except Exception:
100
+ return c1
101
+
102
+
103
+ def _cat_theme_colors():
104
+ fallback = {
105
+ "bg": "#0f0f1a", "surface": "#161625", "surface_hover": "#1e1e35",
106
+ "surface_active": "#252545", "surface_alt": "#1a1a2e",
107
+ "text": "#e4e6f8", "text_muted": "#8d92be",
108
+ "border": "#2a2a48", "border_light": "#3b3b64",
109
+ "accent": "#7c6bff", "accent_soft": "#7c6bff25", "accent_hover": "#8f80ff",
110
+ "success": "#5ee9a0", "warning": "#f0c24a", "danger": "#f06060",
111
+ "gradient_start": "#7c6bff", "gradient_end": "#5ee9a0",
112
+ "hi": "#2e2e48", "sh": "#0a0a14",
113
+ "focus_ring": "#7c6bff",
114
+ "is_light": False,
115
+ }
116
+ try:
117
+ from .. import theme as _theme
118
+ is_light = _theme.is_light() if hasattr(_theme, "is_light") else False
119
+ try:
120
+ obj = _theme.get_theme_obj()
121
+ if hasattr(obj, "dark"):
122
+ is_light = not obj.dark
123
+ bg = obj.hex("background")
124
+ surface = obj.hex("surface")
125
+ accent = obj.hex("accent")
126
+ text = obj.hex("text")
127
+ text_muted = obj.hex("text_muted") if hasattr(obj, "text_muted") else "#8d92be"
128
+ border = obj.hex("border") if hasattr(obj, "border") else "#2a2a48"
129
+ accent_hover = obj.hex("accent_alt") if hasattr(obj, "accent_alt") else accent
130
+ success = obj.hex("success") if hasattr(obj, "success") else fallback["success"]
131
+ warning = obj.hex("warning") if hasattr(obj, "warning") else fallback["warning"]
132
+ danger = obj.hex("error") if hasattr(obj, "error") else fallback["danger"]
133
+
134
+ if is_light:
135
+ hi = "#ffffff"
136
+ sh = _blend_hex(surface, "#000000", 0.18)
137
+ surface_hover = _blend_hex(surface, "#000000", 0.04)
138
+ surface_active = _blend_hex(surface, "#000000", 0.08)
139
+ surface_alt = _blend_hex(surface, "#000000", 0.03)
140
+ focus_ring = accent
141
+ else:
142
+ hi = _blend_hex(surface, "#ffffff", 0.16)
143
+ sh = _blend_hex(surface, "#000000", 0.50)
144
+ surface_hover = obj.hex("surface_hover") if hasattr(obj, "surface_hover") else _blend_hex(surface, "#ffffff", 0.08)
145
+ surface_active = _blend_hex(surface, "#ffffff", 0.14)
146
+ surface_alt = _blend_hex(surface, "#000000", 0.20)
147
+ focus_ring = accent
148
+
149
+ return {
150
+ "bg": bg,
151
+ "surface": surface,
152
+ "surface_hover": surface_hover,
153
+ "surface_active": surface_active,
154
+ "surface_alt": surface_alt,
155
+ "text": text,
156
+ "text_muted": text_muted,
157
+ "border": border,
158
+ "border_light": hi,
159
+ "accent": accent,
160
+ "accent_soft": accent + "25",
161
+ "accent_hover": accent_hover,
162
+ "success": success,
163
+ "warning": warning,
164
+ "danger": danger,
165
+ "gradient_start": accent,
166
+ "gradient_end": success,
167
+ "hi": hi,
168
+ "sh": sh,
169
+ "focus_ring": focus_ring,
170
+ "is_light": is_light,
171
+ }
172
+ except Exception:
173
+ return fallback
174
+ except Exception:
175
+ return fallback
176
+
177
+
178
+ def _friendly_url(url: str) -> str:
179
+ """Hide localhost/Fomoji URLs in address bar — show friendly name instead."""
180
+ if not url or url in ("about:home", "about:blank"):
181
+ return ""
182
+ if "localhost" in url or "127.0.0.1" in url:
183
+ for page, name in _FOMOJI_PAGE_NAMES.items():
184
+ if page in url:
185
+ return name
186
+ return "CAT Internal"
187
+ return url
188
+
189
+
190
+ def _is_search_query(text: str) -> bool:
191
+ t = text.strip()
192
+ if not t:
193
+ return False
194
+ if _URL_RE.match(t):
195
+ return False
196
+ if t.lower().startswith("about:"):
197
+ return False
198
+ if "localhost" in t.lower():
199
+ return False
200
+ if "." in t.split()[0] and " " not in t and "/" in t:
201
+ return False
202
+ if " " in t:
203
+ return True
204
+ if "." not in t and "/" not in t:
205
+ return True
206
+ return False
207
+
208
+
209
+ _SPINNER_FRAMES = ["|", "/", "-", "\\", "|", "/", "-", "\\"]
210
+
211
+
212
+ class _LoadingSpinner:
213
+ """Non-blocking spinner for tab titles."""
214
+ def __init__(self):
215
+ self._frame = 0
216
+
217
+ def next(self) -> str:
218
+ ch = _SPINNER_FRAMES[self._frame % len(_SPINNER_FRAMES)]
219
+ self._frame += 1
220
+ return ch
221
+
222
+
223
+ class _NewTabWidget(QWidget):
224
+ navigateRequested = Signal(str)
225
+
226
+ def __init__(self, parent=None):
227
+ super().__init__(parent)
228
+ c = _cat_theme_colors()
229
+ self.setStyleSheet(f"background: {c['bg']};")
230
+ outer = QVBoxLayout(self)
231
+ outer.setContentsMargins(0, 0, 0, 0)
232
+ outer.setAlignment(Qt.AlignmentFlag.AlignCenter)
233
+
234
+ wrap = QWidget()
235
+ wrap.setMaximumWidth(560)
236
+ v = QVBoxLayout(wrap)
237
+ v.setAlignment(Qt.AlignmentFlag.AlignCenter)
238
+ v.setSpacing(14)
239
+
240
+ logo = QLabel()
241
+ logo.setAlignment(Qt.AlignmentFlag.AlignCenter)
242
+ logo.setTextFormat(Qt.TextFormat.RichText)
243
+ logo.setText(
244
+ f'<span style="font-size:42px;font-weight:800;letter-spacing:6px;'
245
+ f'color:{c["accent"]};">CAT</span>'
246
+ )
247
+ v.addWidget(logo)
248
+
249
+ subtitle = QLabel("Your private, protected browser")
250
+ subtitle.setAlignment(Qt.AlignmentFlag.AlignCenter)
251
+ subtitle.setStyleSheet(f"color: {c['text_muted']}; font-size: 12px; margin-top: -6px;")
252
+ v.addWidget(subtitle)
253
+
254
+ search_frame = QFrame()
255
+ search_frame.setStyleSheet(f"""
256
+ QFrame {{
257
+ background: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 {c['sh']}, stop:0.18 {c['surface']}, stop:1 {c['surface']});
258
+ border-top: 1.5px solid {c['sh']};
259
+ border-left: 1.5px solid {c['sh']};
260
+ border-right: 1px solid {c['hi']};
261
+ border-bottom: 1px solid {c['hi']};
262
+ border-radius: 16px;
263
+ }}
264
+ QFrame:focus-within {{
265
+ border: 2px solid {c['focus_ring']};
266
+ background: {c['surface']};
267
+ }}
268
+ """)
269
+ sh = QHBoxLayout(search_frame)
270
+ sh.setContentsMargins(12, 6, 12, 6)
271
+ sh.setSpacing(6)
272
+ search_icon = QLabel("🔍")
273
+ search_icon.setStyleSheet(f"color: {c['text_muted']}; font-size: 13px; background: transparent;")
274
+ sh.addWidget(search_icon)
275
+ self.search_input = QLineEdit()
276
+ self.search_input.setPlaceholderText("Search the web or enter a URL...")
277
+ self.search_input.setAccessibleName("Search query or URL")
278
+ self.search_input.setAccessibleDescription("Enter search terms or website address")
279
+ self.search_input.setStyleSheet(
280
+ f"background: transparent; border: none; color: {c['text']};"
281
+ f"font-size: 13px; padding: 2px 0;"
282
+ )
283
+ self.search_input.returnPressed.connect(self._on_search)
284
+ sh.addWidget(self.search_input, 1)
285
+ v.addWidget(search_frame)
286
+
287
+ try:
288
+ from PySide6.QtWidgets import QGridLayout
289
+ except ImportError:
290
+ from PyQt6.QtWidgets import QGridLayout # type: ignore
291
+ grid = QGridLayout()
292
+ grid.setSpacing(6)
293
+ shortcuts = [
294
+ ("▶", "YouTube", "https://youtube.com", c["danger"]),
295
+ ("⬢", "GitHub", "https://github.com", c["text"]),
296
+ ("✉", "Gmail", "https://mail.google.com", c["warning"]),
297
+ ("◆", "Fomoji", "http://localhost:3000/connector.html", c["accent"]),
298
+ ("⌂", "Home", "http://localhost:3000/home.html", c["success"]),
299
+ ("🛡", "Security", "http://localhost:3000/security.html", c["warning"]),
300
+ ("👤", "Identities", "http://localhost:3000/identities.html", c["accent"]),
301
+ ("⚙", "Settings", "http://localhost:3000/settings.html", c["text_muted"]),
302
+ ]
303
+ for idx, (icon, label, url, color) in enumerate(shortcuts):
304
+ btn = QPushButton()
305
+ btn.setFixedSize(115, 70)
306
+ btn.setCursor(Qt.CursorShape.PointingHandCursor)
307
+ btn.setAccessibleName(f"{label} shortcut")
308
+ btn.setAccessibleDescription(f"Opens {url}")
309
+ btn.setStyleSheet(f"""
310
+ QPushButton {{
311
+ background: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 {c['surface_hover']}, stop:0.35 {c['surface']}, stop:1 {c['surface']});
312
+ border-top: 1.5px solid {c['hi']};
313
+ border-left: 1.5px solid {c['hi']};
314
+ border-right: 1.5px solid {c['sh']};
315
+ border-bottom: 1.5px solid {c['sh']};
316
+ border-radius: 10px;
317
+ color: {c['text']};
318
+ }}
319
+ QPushButton:hover {{
320
+ border-top: 1.5px solid {color};
321
+ border-left: 1.5px solid {color};
322
+ border-right: 1.5px solid {c['sh']};
323
+ border-bottom: 1.5px solid {c['sh']};
324
+ background: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 {c['surface_active']}, stop:1 {c['surface_hover']});
325
+ }}
326
+ QPushButton:pressed {{
327
+ border-top: 1.5px solid {c['sh']};
328
+ border-left: 1.5px solid {c['sh']};
329
+ border-right: 1.5px solid {c['hi']};
330
+ border-bottom: 1.5px solid {c['hi']};
331
+ padding-top: 2px;
332
+ padding-left: 2px;
333
+ }}
334
+ QPushButton:focus {{
335
+ border: 2px solid {c['focus_ring']};
336
+ outline: none;
337
+ }}
338
+ """)
339
+ btn.setText(f'<span style="font-size:18px;">{icon}</span><br>'
340
+ f'<span style="font-size:11px;color:{c["text_muted"]};">{label}</span>')
341
+ btn.setTextFormat(Qt.TextFormat.RichText)
342
+ btn.clicked.connect(lambda _=None, u=url: self.navigateRequested.emit(u))
343
+ r, col = divmod(idx, 4)
344
+ grid.addWidget(btn, r, col)
345
+ grid_wrap = QWidget()
346
+ grid_wrap.setLayout(grid)
347
+ v.addWidget(grid_wrap)
348
+
349
+ foot = QLabel("CAT Browser · Chromium engine")
350
+ foot.setAlignment(Qt.AlignmentFlag.AlignCenter)
351
+ foot.setStyleSheet(f"color: {c['text_muted']}; font-size: 10px; margin-top: 6px;")
352
+ v.addWidget(foot)
353
+
354
+ outer.addWidget(wrap)
355
+ QTimer.singleShot(100, lambda: self.search_input.setFocus())
356
+
357
+ def _on_search(self):
358
+ text = self.search_input.text().strip()
359
+ if not text:
360
+ return
361
+ has_scheme = bool(_URL_RE.match(text))
362
+ is_local = "localhost" in text.lower()
363
+ if has_scheme or is_local or ("." in text.split()[0] and " " not in text and "/" in text):
364
+ url = text if has_scheme else ("http://" + text if is_local else "https://" + text)
365
+ else:
366
+ url = "https://www.google.com/search?q=" + urllib.parse.quote_plus(text) + "&hl=en"
367
+ self.navigateRequested.emit(url)
368
+
369
+ def focusSearch(self):
370
+ self.search_input.setFocus()
371
+ self.search_input.selectAll()
372
+
373
+
374
+ class CATBrowserWindow(QMainWindow):
375
+ def __init__(self, start_url: str = "about:home"):
376
+ super().__init__()
377
+ self.setWindowTitle("CAT Browser")
378
+ self.resize(1280, 860)
379
+ self.setMinimumSize(960, 640)
380
+
381
+ c = _cat_theme_colors()
382
+ self._colors = c
383
+
384
+ self.state = BrowserState()
385
+ self.profile = QWebEngineProfile("CATBrowser", self)
386
+ try:
387
+ self.profile.setPersistentCookiesPolicy(
388
+ QWebEngineProfile.PersistentCookiesPolicy.ForcePersistentCookies
389
+ )
390
+ _cache_dir = str(Path.home() / ".cat_browser_cache" / "standalone")
391
+ _storage_dir = str(Path.home() / ".cat_browser_storage" / "standalone")
392
+ Path(_cache_dir).mkdir(parents=True, exist_ok=True)
393
+ Path(_storage_dir).mkdir(parents=True, exist_ok=True)
394
+ self.profile.setCachePath(_cache_dir)
395
+ self.profile.setPersistentStoragePath(_storage_dir)
396
+ try:
397
+ self.profile.setHttpCacheType(QWebEngineProfile.HttpCacheType.DiskHttpCache)
398
+ self.profile.setHttpCacheMaximumSize(50 * 1024 * 1024)
399
+ except Exception:
400
+ pass
401
+ except Exception:
402
+ pass
403
+
404
+ central = QWidget()
405
+ central.setStyleSheet(f"background: {c['bg']};")
406
+ self.setCentralWidget(central)
407
+ layout = QVBoxLayout(central)
408
+ layout.setContentsMargins(0, 0, 0, 0)
409
+ layout.setSpacing(0)
410
+
411
+ # ---- Unified chrome surface ----------------------------------------
412
+ chrome = QWidget()
413
+ chrome.setStyleSheet(f"background: {c['bg']};")
414
+ chrome_layout = QVBoxLayout(chrome)
415
+ chrome_layout.setContentsMargins(0, 0, 0, 0)
416
+ chrome_layout.setSpacing(0)
417
+
418
+ # ---- Tab bar row ---------------------------------------------------
419
+ tab_row = QHBoxLayout()
420
+ tab_row.setContentsMargins(6, 4, 4, 0)
421
+ tab_row.setSpacing(0)
422
+
423
+ try:
424
+ from PySide6.QtWidgets import QTabBar as _QTabBar
425
+ except ImportError:
426
+ from PyQt6.QtWidgets import QTabBar as _QTabBar # type: ignore
427
+ self.tab_bar = _QTabBar()
428
+ self.tab_bar.setTabsClosable(True)
429
+ self.tab_bar.setMovable(True)
430
+ self.tab_bar.setExpanding(True)
431
+ self.tab_bar.setDrawBase(False)
432
+ self.tab_bar.tabBarClicked.connect(self._on_tab_clicked)
433
+ self.tab_bar.tabCloseRequested.connect(self._close_tab)
434
+ try:
435
+ self.tab_bar.currentChanged.connect(self._on_current_changed)
436
+ except Exception:
437
+ pass
438
+ self.tab_bar.setStyleSheet(f"""
439
+ QTabBar {{
440
+ background: transparent;
441
+ qproperty-drawBase: 0;
442
+ }}
443
+ QTabBar::tab {{
444
+ background: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 {c['surface']}, stop:1 {c['bg']});
445
+ color: {c['text_muted']};
446
+ border-top: 1px solid {c['hi']};
447
+ border-left: 1px solid {c['hi']};
448
+ border-right: 1px solid {c['sh']};
449
+ border-bottom: 1px solid {c['border']};
450
+ padding: 5px 12px;
451
+ margin-right: 2px;
452
+ margin-top: 2px;
453
+ min-width: 80px;
454
+ max-width: 200px;
455
+ font-size: 12px;
456
+ font-weight: 500;
457
+ border-top-left-radius: 6px;
458
+ border-top-right-radius: 6px;
459
+ }}
460
+ QTabBar::tab:selected {{
461
+ background: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 {c['surface_hover']}, stop:0.15 {c['surface']}, stop:1 {c['surface']});
462
+ color: {c['text']};
463
+ font-weight: 600;
464
+ border-top: 2.5px solid {c['accent']};
465
+ border-left: 1px solid {c['hi']};
466
+ border-right: 1px solid {c['sh']};
467
+ border-bottom: 1px solid {c['surface']};
468
+ margin-top: 0px;
469
+ }}
470
+ QTabBar::tab:hover {{
471
+ background: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 {c['surface_hover']}, stop:1 {c['surface']});
472
+ border-top: 1.5px solid {c['accent']};
473
+ color: {c['text']};
474
+ }}
475
+ QTabBar::close-button {{
476
+ image: none;
477
+ subcontrol-position: right;
478
+ padding: 2px;
479
+ subcontrol-origin: padding;
480
+ border-radius: 8px;
481
+ }}
482
+ QTabBar::close-button:hover {{
483
+ background: {c['danger']};
484
+ border-radius: 8px;
485
+ }}
486
+ """)
487
+ tab_row.addWidget(self.tab_bar, 1)
488
+
489
+ self.new_tab_btn = QPushButton("+")
490
+ self.new_tab_btn.setFixedSize(24, 24)
491
+ self.new_tab_btn.setCursor(Qt.CursorShape.PointingHandCursor)
492
+ self.new_tab_btn.setToolTip("New tab (Ctrl+T)")
493
+ self.new_tab_btn.setAccessibleName("New tab")
494
+ self.new_tab_btn.setAccessibleDescription("Create a new tab (Ctrl+T)")
495
+ self.new_tab_btn.setStyleSheet(f"""
496
+ QPushButton {{
497
+ background: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 {c['surface_hover']}, stop:1 {c['surface']});
498
+ color: {c['text_muted']};
499
+ border-top: 1px solid {c['hi']};
500
+ border-left: 1px solid {c['hi']};
501
+ border-right: 1px solid {c['sh']};
502
+ border-bottom: 1px solid {c['sh']};
503
+ border-radius: 5px;
504
+ font-size: 16px;
505
+ font-weight: bold;
506
+ margin: 0 4px 0 2px;
507
+ }}
508
+ QPushButton:hover {{
509
+ color: {c['accent']};
510
+ background: {c['surface_hover']};
511
+ border-top: 1px solid {c['accent']};
512
+ border-left: 1px solid {c['accent']};
513
+ }}
514
+ QPushButton:pressed {{
515
+ border-top: 1px solid {c['sh']};
516
+ border-left: 1px solid {c['sh']};
517
+ border-right: 1px solid {c['hi']};
518
+ border-bottom: 1px solid {c['hi']};
519
+ padding-top: 2px;
520
+ padding-left: 2px;
521
+ }}
522
+ QPushButton:focus {{
523
+ border: 2px solid {c['focus_ring']};
524
+ }}
525
+ """)
526
+ self.new_tab_btn.clicked.connect(lambda: self.new_tab())
527
+ tab_row.addWidget(self.new_tab_btn, 0)
528
+
529
+ tab_row_w = QWidget()
530
+ tab_row_w.setLayout(tab_row)
531
+ tab_row_w.setFixedHeight(32)
532
+ tab_row_w.setStyleSheet(f"background: {c['bg']};")
533
+ chrome_layout.addWidget(tab_row_w)
534
+
535
+ # ---- Toolbar row (nav + address + menu) ----------------------------
536
+ toolbar = QHBoxLayout()
537
+ toolbar.setContentsMargins(6, 2, 6, 4)
538
+ toolbar.setSpacing(2)
539
+
540
+ nav_btn_style = f"""
541
+ QPushButton {{
542
+ background: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 {c['surface_hover']}, stop:0.5 {c['surface']}, stop:1 {c['surface']});
543
+ color: {c['text']};
544
+ border-top: 1px solid {c['hi']};
545
+ border-left: 1px solid {c['hi']};
546
+ border-right: 1px solid {c['sh']};
547
+ border-bottom: 1px solid {c['sh']};
548
+ border-radius: 5px;
549
+ font-size: 13px;
550
+ font-weight: bold;
551
+ padding: 2px 6px;
552
+ min-width: 24px;
553
+ max-width: 28px;
554
+ min-height: 22px;
555
+ max-height: 22px;
556
+ }}
557
+ QPushButton:hover {{
558
+ color: {c['text']};
559
+ background: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 {c['surface_active']}, stop:1 {c['surface_hover']});
560
+ border-top: 1px solid {c['accent']};
561
+ border-left: 1px solid {c['accent']};
562
+ }}
563
+ QPushButton:pressed {{
564
+ border-top: 1px solid {c['sh']};
565
+ border-left: 1px solid {c['sh']};
566
+ border-right: 1px solid {c['hi']};
567
+ border-bottom: 1px solid {c['hi']};
568
+ padding-top: 3px;
569
+ padding-left: 7px;
570
+ }}
571
+ QPushButton:disabled {{
572
+ background: transparent;
573
+ border: 1px solid transparent;
574
+ color: {c['border']};
575
+ }}
576
+ QPushButton:focus {{
577
+ border: 2px solid {c['focus_ring']};
578
+ }}
579
+ """
580
+
581
+ self.back_btn = QPushButton("◀")
582
+ self.back_btn.setFixedSize(26, 22)
583
+ self.back_btn.setToolTip("Back (Alt+Left)")
584
+ self.back_btn.setAccessibleName("Back")
585
+ self.back_btn.setAccessibleDescription("Navigate back in history (Alt+Left)")
586
+ self.back_btn.setStyleSheet(nav_btn_style)
587
+ self.back_btn.clicked.connect(self.go_back)
588
+ toolbar.addWidget(self.back_btn)
589
+
590
+ self.forward_btn = QPushButton("▶")
591
+ self.forward_btn.setFixedSize(26, 22)
592
+ self.forward_btn.setToolTip("Forward (Alt+Right)")
593
+ self.forward_btn.setAccessibleName("Forward")
594
+ self.forward_btn.setAccessibleDescription("Navigate forward in history (Alt+Right)")
595
+ self.forward_btn.setStyleSheet(nav_btn_style)
596
+ self.forward_btn.clicked.connect(self.go_forward)
597
+ toolbar.addWidget(self.forward_btn)
598
+
599
+ self.reload_btn = QPushButton("⟳")
600
+ self.reload_btn.setFixedSize(26, 22)
601
+ self.reload_btn.setToolTip("Reload (Ctrl+R)")
602
+ self.reload_btn.setAccessibleName("Reload")
603
+ self.reload_btn.setAccessibleDescription("Reload current page (Ctrl+R)")
604
+ self.reload_btn.setStyleSheet(nav_btn_style)
605
+ self.reload_btn.clicked.connect(self.reload)
606
+ toolbar.addWidget(self.reload_btn)
607
+
608
+ toolbar.addSpacing(4)
609
+
610
+ # Address bar — sunken 3D well with accessible focus
611
+ addr_frame = QFrame()
612
+ addr_frame.setStyleSheet(f"""
613
+ QFrame {{
614
+ background: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 {c['sh']}, stop:0.2 {c['surface']}, stop:1 {c['surface']});
615
+ border-top: 1.5px solid {c['sh']};
616
+ border-left: 1.5px solid {c['sh']};
617
+ border-right: 1px solid {c['hi']};
618
+ border-bottom: 1px solid {c['hi']};
619
+ border-radius: 12px;
620
+ }}
621
+ QFrame:focus-within {{
622
+ border: 2px solid {c['focus_ring']};
623
+ background: {c['surface']};
624
+ }}
625
+ """)
626
+ addr_layout = QHBoxLayout(addr_frame)
627
+ addr_layout.setContentsMargins(8, 1, 8, 1)
628
+ addr_layout.setSpacing(4)
629
+
630
+ self.lock_label = QLabel("🔒")
631
+ self.lock_label.setFixedWidth(20)
632
+ self.lock_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
633
+ self.lock_label.setToolTip("Security Status")
634
+ self.lock_label.setAccessibleName("Security connection status")
635
+ self.lock_label.setStyleSheet(f"""
636
+ color: {c['accent']};
637
+ font-size: 11px;
638
+ background: {c['accent_soft']};
639
+ border: 1px solid {c['hi']};
640
+ border-radius: 6px;
641
+ padding: 1px 2px;
642
+ """)
643
+ addr_layout.addWidget(self.lock_label)
644
+
645
+ self.address_input = QLineEdit()
646
+ self.address_input.setPlaceholderText("Search or enter address")
647
+ self.address_input.setAccessibleName("Address or search query")
648
+ self.address_input.setAccessibleDescription("Enter URL or search query and press Enter")
649
+ self.address_input.returnPressed.connect(self._on_address_enter)
650
+ self.address_input.setClearButtonEnabled(True)
651
+ self.address_input.setStyleSheet(f"""
652
+ background: transparent; border: none; color: {c['text']};
653
+ font-size: 12px; padding: 0 2px;
654
+ selection-background-color: {c['accent']};
655
+ """)
656
+ addr_layout.addWidget(self.address_input, 1)
657
+
658
+ toolbar.addWidget(addr_frame, 1)
659
+
660
+ self.menu_btn = QPushButton("⋮")
661
+ self.menu_btn.setFixedSize(26, 22)
662
+ self.menu_btn.setToolTip("Menu")
663
+ self.menu_btn.setAccessibleName("Menu")
664
+ self.menu_btn.setAccessibleDescription("Browser settings and options")
665
+ self.menu_btn.setStyleSheet(nav_btn_style)
666
+ self.menu_btn.clicked.connect(self.show_menu)
667
+ toolbar.addWidget(self.menu_btn)
668
+
669
+ toolbar_w = QWidget()
670
+ toolbar_w.setLayout(toolbar)
671
+ toolbar_w.setFixedHeight(30)
672
+ toolbar_w.setStyleSheet(f"""
673
+ QWidget {{
674
+ background: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 {c['bg']}, stop:1 {c['surface']});
675
+ border-bottom: 1px solid {c['sh']};
676
+ }}
677
+ """)
678
+ chrome_layout.addWidget(toolbar_w)
679
+
680
+ # ---- Progress bar (3D recessed channel with glowing fill) ----------
681
+ self.progress_bar = QProgressBar()
682
+ self.progress_bar.setFixedHeight(3)
683
+ self.progress_bar.setTextVisible(False)
684
+ self.progress_bar.setRange(0, 100)
685
+ self.progress_bar.setValue(0)
686
+ self.progress_bar.setAccessibleName("Page loading progress")
687
+ self.progress_bar.setStyleSheet(f"""
688
+ QProgressBar {{
689
+ background: {c['sh']};
690
+ border-top: 1px solid {c['sh']};
691
+ border-bottom: 1px solid {c['hi']};
692
+ border-radius: 0px;
693
+ }}
694
+ QProgressBar::chunk {{
695
+ background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
696
+ stop:0 {c['accent']}, stop:0.7 {c['gradient_end']}, stop:1 #ffffff);
697
+ border-radius: 1px;
698
+ }}
699
+ """)
700
+ self.progress_bar.hide()
701
+ chrome_layout.addWidget(self.progress_bar)
702
+
703
+ layout.addWidget(chrome)
704
+
705
+ self.viewport_stack = QStackedWidget()
706
+ self.viewport_stack.setStyleSheet(f"background: {c['bg']};")
707
+ layout.addWidget(self.viewport_stack, 1)
708
+
709
+ self._views: list[QWidget] = []
710
+ self._view_index: dict[int, int] = {} # id(view) -> index for O(1) lookup
711
+ self._spinners: dict[int, _LoadingSpinner] = {}
712
+ self._pending_start_url = start_url
713
+ QTimer.singleShot(0, self._create_initial_tab)
714
+
715
+ self._setup_shortcuts()
716
+ self._apply_theme()
717
+
718
+ self._timer = QTimer(self)
719
+ self._timer.timeout.connect(self._tick_spinner)
720
+ self._timer.start(200)
721
+
722
+ def _tick_spinner(self):
723
+ for idx, sp in list(self._spinners.items()):
724
+ if 0 <= idx < self.tab_bar.count():
725
+ ch = sp.next()
726
+ if 0 <= idx < len(self.state.tabs):
727
+ title = self.state.tabs[idx].title[:28]
728
+ self.tab_bar.setTabText(idx, f"{ch} {title}")
729
+
730
+ def _view_index(self, view: QWidget) -> int:
731
+ """O(1) view index lookup via cached dict."""
732
+ return self._view_index.get(id(view), -1)
733
+
734
+ def _rebuild_view_index(self):
735
+ """Rebuild the O(1) index cache after list changes."""
736
+ self._view_index = {id(v): i for i, v in enumerate(self._views)}
737
+
738
+ def _setup_shortcuts(self):
739
+ shortcuts = [
740
+ ("Ctrl+L", self._focus_address),
741
+ ("Ctrl+T", lambda: self.new_tab()),
742
+ ("Ctrl+W", self._close_active_tab),
743
+ ("Ctrl+R", self.reload),
744
+ ("F5", self.reload),
745
+ ("Ctrl+Shift+T", self.reopen_closed),
746
+ ("Alt+Left", self.go_back),
747
+ ("Alt+Right", self.go_forward),
748
+ ("F11", self.toggle_fullscreen),
749
+ ("Escape", self._handle_escape),
750
+ ]
751
+ for seq, fn in shortcuts:
752
+ try:
753
+ sc = QShortcut(QKeySequence(seq), self)
754
+ sc.activated.connect(fn) # type: ignore
755
+ except Exception:
756
+ pass
757
+
758
+ def _create_initial_tab(self):
759
+ try:
760
+ self._new_tab(url=getattr(self, "_pending_start_url", "about:home"), switch=True)
761
+ except Exception:
762
+ pass
763
+
764
+ def _apply_theme(self):
765
+ c = self._colors
766
+ self.setStyleSheet(f"""
767
+ QMainWindow {{ background: {c['bg']}; }}
768
+ QLineEdit {{ selection-background-color: {c['accent']}; }}
769
+ QToolTip {{
770
+ background: {c['surface']}; color: {c['text']};
771
+ border: 1px solid {c['border']}; border-radius: 6px;
772
+ padding: 4px 8px; font-size: 12px;
773
+ }}
774
+ """)
775
+
776
+ def _new_tab(self, url: str = "about:home", switch: bool = True) -> int:
777
+ tab = self.state.new_tab(url)
778
+ is_new_tab = url in ("about:home", "about:blank")
779
+ if is_new_tab:
780
+ view = _NewTabWidget()
781
+ try:
782
+ view.navigateRequested.connect(lambda u, idx=None: self._handle_newtab_navigate(u))
783
+ except Exception:
784
+ pass
785
+ self._newtab_widget = view
786
+ else:
787
+ view = QWebEngineView()
788
+
789
+ if not is_new_tab:
790
+ try:
791
+ page = QWebEnginePage(self.profile, view)
792
+ view.setPage(page)
793
+ except Exception:
794
+ pass
795
+ try:
796
+ view.titleChanged.connect(lambda title, v=view: self._on_title_changed(v, title))
797
+ view.urlChanged.connect(lambda qurl, v=view: self._on_url_changed(v, qurl))
798
+ view.loadStarted.connect(lambda v=view: self._on_load_started(v))
799
+ view.loadFinished.connect(lambda ok, v=view: self._on_load_finished(v, ok))
800
+ view.iconChanged.connect(lambda icon, v=view: self._on_icon_changed(v, icon))
801
+ view.page().newWindowRequested.connect(lambda req: self._on_new_window_requested(req))
802
+ try:
803
+ view.loadProgress.connect(
804
+ lambda p, v=view: self._on_load_progress(v, p)
805
+ )
806
+ except Exception:
807
+ pass
808
+ except Exception:
809
+ pass
810
+ try:
811
+ if not getattr(self, "_download_connected", False):
812
+ self.profile.downloadRequested.connect(self._on_download_requested)
813
+ self._download_connected = True
814
+ except Exception:
815
+ pass
816
+
817
+ idx = self.viewport_stack.addWidget(view)
818
+ self._views.append(view)
819
+ self._rebuild_view_index()
820
+ tab_idx = self.tab_bar.addTab(f"{tab.favicon} {tab.display_title()[:28]}")
821
+ if switch:
822
+ self.tab_bar.setCurrentIndex(tab_idx)
823
+ self.viewport_stack.setCurrentIndex(idx)
824
+ self._sync_address()
825
+ if not is_new_tab:
826
+ self._load_in_view(view, url)
827
+ else:
828
+ try:
829
+ QTimer.singleShot(100, lambda: view.focusSearch())
830
+ except Exception:
831
+ pass
832
+ return tab_idx
833
+
834
+ def _handle_newtab_navigate(self, url: str):
835
+ idx = self.tab_bar.currentIndex()
836
+ if not (0 <= idx < len(self._views)):
837
+ self.navigate(url)
838
+ return
839
+ old = self._views[idx]
840
+ if not isinstance(old, _NewTabWidget):
841
+ self.navigate(url)
842
+ return
843
+ self.viewport_stack.removeWidget(old)
844
+ old.deleteLater()
845
+ view = QWebEngineView()
846
+ try:
847
+ page = QWebEnginePage(self.profile, view)
848
+ view.setPage(page)
849
+ view.titleChanged.connect(lambda title, v=view: self._on_title_changed(v, title))
850
+ view.urlChanged.connect(lambda qurl, v=view: self._on_url_changed(v, qurl))
851
+ view.loadStarted.connect(lambda v=view: self._on_load_started(v))
852
+ view.loadFinished.connect(lambda ok, v=view: self._on_load_finished(v, ok))
853
+ view.page().newWindowRequested.connect(lambda req: self._on_new_window_requested(req))
854
+ try:
855
+ view.loadProgress.connect(
856
+ lambda p, v=view: self._on_load_progress(v, p)
857
+ )
858
+ except Exception:
859
+ pass
860
+ except Exception:
861
+ pass
862
+ self.viewport_stack.insertWidget(idx, view)
863
+ self.viewport_stack.setCurrentIndex(idx)
864
+ self._views[idx] = view
865
+ self._rebuild_view_index()
866
+ tab = self.state.tabs[idx] if 0 <= idx < len(self.state.tabs) else None
867
+ if tab:
868
+ tab.url = url
869
+ tab.title = "Loading…"
870
+ self.tab_bar.setTabText(idx, "⟳ Loading…")
871
+ self.progress_bar.setValue(0)
872
+ self.progress_bar.show()
873
+ self._load_in_view(view, url)
874
+
875
+ def _load_in_view(self, view, url: str):
876
+ if url in ("about:home", "about:blank"):
877
+ return
878
+ try:
879
+ view.load(QUrl(url))
880
+ except Exception:
881
+ pass
882
+
883
+ def _on_tab_clicked(self, index: int):
884
+ if 0 <= index < len(self._views):
885
+ self.tab_bar.setCurrentIndex(index)
886
+ self.viewport_stack.setCurrentIndex(index)
887
+ if 0 <= index < len(self.state.tabs):
888
+ self.state.active_tab_id = self.state.tabs[index].id
889
+ self._sync_address()
890
+
891
+ def _close_tab(self, index: int):
892
+ if not (0 <= index < len(self._views)):
893
+ return
894
+ view = self._views.pop(index)
895
+ self._spinners.pop(index, None)
896
+ self.viewport_stack.removeWidget(view)
897
+ view.deleteLater()
898
+ self.tab_bar.removeTab(index)
899
+ self._rebuild_view_index()
900
+ if 0 <= index < len(self.state.tabs):
901
+ self.state.close_tab(self.state.tabs[index].id)
902
+ if self.tab_bar.count() == 0:
903
+ self.progress_bar.hide()
904
+ self.new_tab("about:home")
905
+ else:
906
+ new_idx = min(index, self.tab_bar.count() - 1)
907
+ self.tab_bar.setCurrentIndex(new_idx)
908
+ self.viewport_stack.setCurrentIndex(new_idx)
909
+ if 0 <= new_idx < len(self.state.tabs):
910
+ self.state.active_tab_id = self.state.tabs[new_idx].id
911
+ self._sync_address()
912
+
913
+ def _close_active_tab(self):
914
+ self._close_tab(self.tab_bar.currentIndex())
915
+
916
+ def new_tab(self, url: str = "about:home"):
917
+ self._new_tab(url, switch=True)
918
+
919
+ def navigate(self, raw: str):
920
+ url = raw.strip()
921
+ if not url:
922
+ return
923
+ has_scheme = bool(_URL_RE.match(url))
924
+ is_about = url.lower().startswith("about:")
925
+ if _is_search_query(url) and not has_scheme and not is_about and "localhost" not in url.lower():
926
+ q = urllib.parse.quote_plus(url)
927
+ url = f"https://www.google.com/search?q={q}&hl=en"
928
+ elif not has_scheme and not is_about and "." in url.split("/")[0] and " " not in url:
929
+ url = "https://" + url
930
+
931
+ tab = self.state.active_tab
932
+ tab.url = url
933
+ tab.title = "Loading..."
934
+ self.state.navigate_active(url)
935
+ self._sync_address()
936
+ idx = self.tab_bar.currentIndex()
937
+ if 0 <= idx < len(self._views):
938
+ view = self._views[idx]
939
+ if isinstance(view, _NewTabWidget) and url not in ("about:home", "about:blank"):
940
+ self._handle_newtab_navigate(url)
941
+ self._update_bookmark_star()
942
+ return
943
+ if not isinstance(view, _NewTabWidget) and url in ("about:home", "about:blank"):
944
+ try:
945
+ self.viewport_stack.removeWidget(view)
946
+ view.deleteLater()
947
+ new_w = _NewTabWidget()
948
+ new_w.navigateRequested.connect(lambda u: self._handle_newtab_navigate(u))
949
+ self.viewport_stack.insertWidget(idx, new_w)
950
+ self.viewport_stack.setCurrentIndex(idx)
951
+ self._views[idx] = new_w
952
+ tab.title = "New Tab"
953
+ tab.favicon = "○"
954
+ self.tab_bar.setTabText(idx, "○ New Tab")
955
+ self._sync_address()
956
+ QTimer.singleShot(80, lambda: new_w.focusSearch())
957
+ except Exception:
958
+ self._load_in_view(view, url)
959
+ self._update_bookmark_star()
960
+ return
961
+ self._load_in_view(view, url)
962
+ self.tab_bar.setTabText(idx, f"⟳ Loading...")
963
+ self.progress_bar.setValue(0)
964
+ self.progress_bar.show()
965
+ self._update_bookmark_star()
966
+
967
+ def _on_address_enter(self):
968
+ self.navigate(self.address_input.text())
969
+
970
+ def _on_title_changed(self, view, title: str):
971
+ try:
972
+ idx = self._view_index(view)
973
+ if idx < 0:
974
+ return
975
+ if 0 <= idx < len(self.state.tabs):
976
+ self.state.tabs[idx].title = title or "Untitled"
977
+ self.state.update_active(self.state.tabs[idx].url, title)
978
+ if idx not in self._spinners:
979
+ display = title[:28] if title else "Untitled"
980
+ self.tab_bar.setTabText(idx, f"{self.state.tabs[idx].favicon} {display}")
981
+ if idx == self.tab_bar.currentIndex():
982
+ self.setWindowTitle(f"CAT Browser — {title[:40]}")
983
+ except (ValueError, IndexError):
984
+ pass
985
+
986
+ def _on_url_changed(self, view, qurl):
987
+ try:
988
+ idx = self._view_index(view)
989
+ if idx < 0:
990
+ return
991
+ url = qurl.toString()
992
+ if 0 <= idx < len(self.state.tabs):
993
+ self.state.tabs[idx].url = url
994
+ self.state.tabs[idx].favicon = "🔒" if url.startswith("https://") else "○"
995
+ title = self.state.tabs[idx].title
996
+ if idx not in self._spinners:
997
+ display = title[:28] if title else "Untitled"
998
+ self.tab_bar.setTabText(idx, f"{self.state.tabs[idx].favicon} {display}")
999
+ if idx == self.tab_bar.currentIndex():
1000
+ display = _friendly_url(url)
1001
+ self.address_input.setText(display)
1002
+ self.lock_label.setText("🔒" if url.startswith("https://") else "🌐")
1003
+ self._update_bookmark_star()
1004
+ except (ValueError, IndexError):
1005
+ pass
1006
+
1007
+ def _on_load_progress(self, view, progress: int):
1008
+ try:
1009
+ idx = self._view_index(view)
1010
+ if idx < 0:
1011
+ return
1012
+ if idx == self.tab_bar.currentIndex():
1013
+ self.progress_bar.setValue(progress)
1014
+ except (ValueError, IndexError):
1015
+ pass
1016
+
1017
+ def _on_load_started(self, view):
1018
+ try:
1019
+ idx = self._view_index(view)
1020
+ if idx < 0:
1021
+ return
1022
+ if idx == self.tab_bar.currentIndex():
1023
+ self.reload_btn.setText("✕")
1024
+ self.reload_btn.setToolTip("Stop loading")
1025
+ self.progress_bar.setValue(0)
1026
+ self.progress_bar.show()
1027
+ if 0 <= idx < len(self.state.tabs):
1028
+ self.state.tabs[idx].loading = True
1029
+ sp = _LoadingSpinner()
1030
+ self._spinners[idx] = sp
1031
+ self.tab_bar.setTabText(idx, f"{sp.next()} {self.state.tabs[idx].title[:28]}")
1032
+ except (ValueError, IndexError):
1033
+ pass
1034
+
1035
+ def _on_load_finished(self, view, ok: bool):
1036
+ try:
1037
+ idx = self._view_index(view)
1038
+ if idx < 0:
1039
+ return
1040
+ if 0 <= idx < len(self.state.tabs):
1041
+ self.state.tabs[idx].loading = False
1042
+ self._spinners.pop(idx, None)
1043
+ title = view.title() or self.state.tabs[idx].url
1044
+ self.state.tabs[idx].title = title[:60]
1045
+ display = title[:28] if title else "Untitled"
1046
+ self.tab_bar.setTabText(idx, f"{self.state.tabs[idx].favicon} {display}")
1047
+ self.state.update_active(self.state.tabs[idx].url, title)
1048
+ if idx == self.tab_bar.currentIndex():
1049
+ self.reload_btn.setText("⟳")
1050
+ self.reload_btn.setToolTip("Reload (Ctrl+R)")
1051
+ self.progress_bar.hide()
1052
+ self.progress_bar.setValue(0)
1053
+ self._sync_address()
1054
+ except (ValueError, IndexError):
1055
+ pass
1056
+
1057
+ def _on_icon_changed(self, view, icon):
1058
+ pass
1059
+
1060
+ def _on_new_window_requested(self, request):
1061
+ try:
1062
+ url = request.requestedUrl().toString()
1063
+ self.new_tab(url)
1064
+ view = self._views[-1]
1065
+ try:
1066
+ request.openIn(view.page())
1067
+ except Exception:
1068
+ view.load(QUrl(url))
1069
+ except Exception:
1070
+ pass
1071
+
1072
+ def _on_download_requested(self, download):
1073
+ try:
1074
+ filename = download.downloadFileName() or "download"
1075
+ path = str(Path.home() / "Downloads" / filename)
1076
+ download.setDownloadDirectory(str(Path.home() / "Downloads"))
1077
+ download.setDownloadFileName(filename)
1078
+ download.accept()
1079
+ self.state.add_download(filename, download.url().toString(), path)
1080
+ download.finished.connect(lambda: self._on_download_finished(download))
1081
+ except Exception:
1082
+ try:
1083
+ download.accept()
1084
+ except Exception:
1085
+ pass
1086
+
1087
+ def _on_download_finished(self, download):
1088
+ pass
1089
+
1090
+ def _sync_address(self):
1091
+ try:
1092
+ tab = self.state.active_tab
1093
+ display = _friendly_url(tab.url)
1094
+ if self.address_input.text() != display:
1095
+ self.address_input.setText(display)
1096
+ lock = "🔒" if tab.url.startswith("https") else "🌐"
1097
+ if self.lock_label.text() != lock:
1098
+ self.lock_label.setText(lock)
1099
+ self.back_btn.setEnabled(self.state.can_go_back())
1100
+ self.forward_btn.setEnabled(self.state.can_go_forward())
1101
+ title = tab.display_title()
1102
+ new_title = f"CAT Browser — {title}"
1103
+ if self.windowTitle() != new_title:
1104
+ self.setWindowTitle(new_title)
1105
+ # Sync current tab text with favicon
1106
+ idx = self.tab_bar.currentIndex()
1107
+ if 0 <= idx < len(self.state.tabs):
1108
+ fav = self.state.tabs[idx].favicon or "○"
1109
+ title_text = self.state.tabs[idx].title[:28] if self.state.tabs[idx].title else "Untitled"
1110
+ new_tab_text = f"{fav} {title_text}"
1111
+ if self.tab_bar.tabText(idx) != new_tab_text:
1112
+ self.tab_bar.setTabText(idx, new_tab_text)
1113
+ except Exception:
1114
+ pass
1115
+
1116
+ def _update_bookmark_star(self):
1117
+ pass # Bookmark removed for cleaner toolbar
1118
+
1119
+ def go_back(self):
1120
+ idx = self.tab_bar.currentIndex()
1121
+ if 0 <= idx < len(self._views) and hasattr(self._views[idx], "back"):
1122
+ self._views[idx].back()
1123
+
1124
+ def go_forward(self):
1125
+ idx = self.tab_bar.currentIndex()
1126
+ if 0 <= idx < len(self._views) and hasattr(self._views[idx], "forward"):
1127
+ self._views[idx].forward()
1128
+
1129
+ def reload(self):
1130
+ idx = self.tab_bar.currentIndex()
1131
+ if 0 <= idx < len(self._views):
1132
+ v = self._views[idx]
1133
+ if hasattr(v, "isLoading") and v.isLoading():
1134
+ v.stop()
1135
+ self.reload_btn.setText("⟳")
1136
+ self.progress_bar.hide()
1137
+ elif hasattr(v, "reload"):
1138
+ v.reload()
1139
+ self.progress_bar.setValue(0)
1140
+ self.progress_bar.show()
1141
+
1142
+ def toggle_bookmark(self):
1143
+ pass # Bookmark removed for cleaner toolbar
1144
+
1145
+ def show_menu(self):
1146
+ try:
1147
+ from PySide6.QtWidgets import QMenu as _QMenu
1148
+ except ImportError:
1149
+ from PyQt6.QtWidgets import QMenu as _QMenu # type: ignore
1150
+ c = self._colors
1151
+ menu = _QMenu(self)
1152
+ menu.setStyleSheet(f"""
1153
+ QMenu {{
1154
+ background: {c['surface']};
1155
+ color: {c['text']};
1156
+ border-top: 1px solid {c['hi']};
1157
+ border-left: 1px solid {c['hi']};
1158
+ border-right: 1.5px solid {c['sh']};
1159
+ border-bottom: 1.5px solid {c['sh']};
1160
+ border-radius: 8px;
1161
+ padding: 6px;
1162
+ }}
1163
+ QMenu::item {{
1164
+ padding: 6px 22px 6px 12px;
1165
+ border-radius: 5px;
1166
+ margin: 1px 2px;
1167
+ }}
1168
+ QMenu::item:selected {{
1169
+ background: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 {c['accent_hover']}, stop:1 {c['accent']});
1170
+ color: #ffffff;
1171
+ }}
1172
+ QMenu::separator {{
1173
+ height: 1px;
1174
+ background: {c['border']};
1175
+ margin: 4px 8px;
1176
+ }}
1177
+ """)
1178
+ actions = [
1179
+ ("New Tab", lambda: self.new_tab()),
1180
+ ("New Window", lambda: self.new_tab()),
1181
+ (None, None),
1182
+ ("History", self.show_history),
1183
+ ("Bookmarks", self.show_bookmarks),
1184
+ ("Downloads", self.show_downloads),
1185
+ (None, None),
1186
+ ("Find on Page", lambda: self._focus_address()),
1187
+ ("Developer Tools", self.show_devtools),
1188
+ (None, None),
1189
+ ("Settings", lambda: self.navigate("http://localhost:3000/settings.html")),
1190
+ ]
1191
+ for label, fn in actions:
1192
+ if label is None:
1193
+ menu.addSeparator()
1194
+ else:
1195
+ act = menu.addAction(label)
1196
+ if fn:
1197
+ act.triggered.connect(fn)
1198
+ menu.exec(self.menu_btn.mapToGlobal(self.menu_btn.rect().bottomLeft()))
1199
+
1200
+ def show_history(self):
1201
+ dlg = HistoryDialog(self.state, self)
1202
+ dlg.exec()
1203
+
1204
+ def show_bookmarks(self):
1205
+ dlg = BookmarksDialog(self.state, self)
1206
+
1207
+ def on_open(url):
1208
+ dlg.accept()
1209
+ self.navigate(url)
1210
+
1211
+ dlg.bookmark_opened.connect(on_open)
1212
+ dlg.exec()
1213
+
1214
+ def show_downloads(self):
1215
+ dlg = DownloadsDialog(self.state, self)
1216
+ dlg.exec()
1217
+
1218
+ def show_devtools(self):
1219
+ dlg = DevToolsDialog(self)
1220
+ dlg.exec()
1221
+
1222
+ def reopen_closed(self):
1223
+ tab = self.state.reopen_closed()
1224
+ if tab:
1225
+ self.new_tab(tab.url)
1226
+
1227
+ def toggle_fullscreen(self):
1228
+ if self.isFullScreen():
1229
+ self.showNormal()
1230
+ else:
1231
+ self.showFullScreen()
1232
+
1233
+ def _focus_address(self):
1234
+ self.address_input.selectAll()
1235
+ self.address_input.setFocus()
1236
+
1237
+ def _handle_escape(self):
1238
+ pass
1239
+
1240
+ def closeEvent(self, event):
1241
+ try:
1242
+ if hasattr(self, '_timer'):
1243
+ self._timer.stop()
1244
+ except Exception:
1245
+ pass
1246
+ event.accept()
1247
+
1248
+
1249
+ class HistoryDialog(QDialog):
1250
+ def __init__(self, state: BrowserState, parent=None):
1251
+ super().__init__(parent)
1252
+ self.state = state
1253
+ c = _cat_theme_colors()
1254
+ self.setWindowTitle("History — CAT Browser")
1255
+ self.resize(700, 500)
1256
+ self.setStyleSheet(f"""
1257
+ QDialog {{ background: {c['bg']}; color: {c['text']}; }}
1258
+ QLabel {{ color: {c['text']}; font-size: 14px; font-weight: bold; }}
1259
+ QListWidget {{
1260
+ background: {c['surface']}; color: {c['text']};
1261
+ border-top: 1.5px solid {c['sh']}; border-left: 1.5px solid {c['sh']};
1262
+ border-right: 1px solid {c['hi']}; border-bottom: 1px solid {c['hi']};
1263
+ border-radius: 8px; padding: 4px; font-size: 12px;
1264
+ }}
1265
+ QListWidget::item {{ padding: 6px 8px; border-radius: 4px; }}
1266
+ QListWidget::item:selected {{
1267
+ background: {c['accent']}; color: #ffffff;
1268
+ border-top: 1px solid {c['hi']}; border-bottom: 1px solid {c['sh']};
1269
+ }}
1270
+ QListWidget::item:hover {{ background: {c['surface_hover']}; }}
1271
+ QPushButton {{
1272
+ background: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 {c['surface_hover']}, stop:1 {c['surface']});
1273
+ color: {c['text']};
1274
+ border-top: 1px solid {c['hi']}; border-left: 1px solid {c['hi']};
1275
+ border-right: 1px solid {c['sh']}; border-bottom: 1px solid {c['sh']};
1276
+ border-radius: 6px; padding: 6px 16px; font-weight: 500;
1277
+ }}
1278
+ QPushButton:hover {{ border-color: {c['accent']}; }}
1279
+ QPushButton:pressed {{
1280
+ border-top: 1px solid {c['sh']}; border-left: 1px solid {c['sh']};
1281
+ border-right: 1px solid {c['hi']}; border-bottom: 1px solid {c['hi']};
1282
+ padding-top: 7px; padding-left: 17px;
1283
+ }}
1284
+ QPushButton:focus {{ border: 2px solid {c['focus_ring']}; }}
1285
+ """)
1286
+ layout = QVBoxLayout(self)
1287
+ layout.setSpacing(10)
1288
+ label = QLabel("History")
1289
+ layout.addWidget(label)
1290
+ self.list_widget = QListWidget()
1291
+ groups = state.history_grouped()
1292
+ for gname in ("Today", "Yesterday", "Earlier"):
1293
+ entries = groups.get(gname, [])
1294
+ if not entries:
1295
+ continue
1296
+ header = QListWidgetItem(f"--- {gname} ---")
1297
+ header.setFlags(header.flags() & ~Qt.ItemFlag.ItemIsSelectable)
1298
+ header.setForeground(QColor(c["text_muted"]))
1299
+ self.list_widget.addItem(header)
1300
+ for h in entries[:50]:
1301
+ display = _friendly_url(h.url) if "localhost" in h.url else h.url[:60]
1302
+ item = QListWidgetItem(
1303
+ f"{h.favicon} {h.title[:40]} | {display} [{time.strftime('%H:%M', time.localtime(h.timestamp))}]"
1304
+ )
1305
+ item.setData(Qt.ItemDataRole.UserRole, h.url)
1306
+ self.list_widget.addItem(item)
1307
+ self.list_widget.itemDoubleClicked.connect(self._open)
1308
+ layout.addWidget(self.list_widget)
1309
+ btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
1310
+ btns.rejected.connect(self.reject)
1311
+ btns.accepted.connect(self.accept)
1312
+ layout.addWidget(btns)
1313
+
1314
+ def _open(self, item):
1315
+ url = item.data(Qt.ItemDataRole.UserRole)
1316
+ if url:
1317
+ parent = self.parent()
1318
+ if hasattr(parent, "navigate"):
1319
+ parent.navigate(url)
1320
+ self.accept()
1321
+
1322
+
1323
+ class BookmarksDialog(QDialog):
1324
+ bookmark_opened = Signal(str)
1325
+
1326
+ def __init__(self, state: BrowserState, parent=None):
1327
+ super().__init__(parent)
1328
+ self.state = state
1329
+ c = _cat_theme_colors()
1330
+ self.setWindowTitle("Bookmarks — CAT Browser")
1331
+ self.resize(600, 400)
1332
+ self.setStyleSheet(f"""
1333
+ QDialog {{ background: {c['bg']}; color: {c['text']}; }}
1334
+ QLabel {{ color: {c['text']}; font-size: 14px; font-weight: bold; }}
1335
+ QListWidget {{
1336
+ background: {c['surface']}; color: {c['text']};
1337
+ border-top: 1.5px solid {c['sh']}; border-left: 1.5px solid {c['sh']};
1338
+ border-right: 1px solid {c['hi']}; border-bottom: 1px solid {c['hi']};
1339
+ border-radius: 8px; padding: 4px; font-size: 12px;
1340
+ }}
1341
+ QListWidget::item {{ padding: 6px 8px; border-radius: 4px; }}
1342
+ QListWidget::item:selected {{
1343
+ background: {c['accent']}; color: #ffffff;
1344
+ border-top: 1px solid {c['hi']}; border-bottom: 1px solid {c['sh']};
1345
+ }}
1346
+ QPushButton {{
1347
+ background: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 {c['surface_hover']}, stop:1 {c['surface']});
1348
+ color: {c['text']};
1349
+ border-top: 1px solid {c['hi']}; border-left: 1px solid {c['hi']};
1350
+ border-right: 1px solid {c['sh']}; border-bottom: 1px solid {c['sh']};
1351
+ border-radius: 6px; padding: 6px 16px; font-weight: 500;
1352
+ }}
1353
+ QPushButton:hover {{ border-color: {c['accent']}; }}
1354
+ QPushButton:pressed {{
1355
+ border-top: 1px solid {c['sh']}; border-left: 1px solid {c['sh']};
1356
+ border-right: 1px solid {c['hi']}; border-bottom: 1px solid {c['hi']};
1357
+ padding-top: 7px; padding-left: 17px;
1358
+ }}
1359
+ QPushButton:focus {{ border: 2px solid {c['focus_ring']}; }}
1360
+ """)
1361
+ layout = QVBoxLayout(self)
1362
+ layout.addWidget(QLabel("Bookmarks"))
1363
+ self.list_widget = QListWidget()
1364
+ for bm in state.bookmarks:
1365
+ display = _friendly_url(bm.url) if "localhost" in bm.url else bm.url
1366
+ item = QListWidgetItem(f"{bm.favicon} {bm.title} | {display}")
1367
+ item.setData(Qt.ItemDataRole.UserRole, bm.id)
1368
+ self.list_widget.addItem(item)
1369
+ self.list_widget.itemDoubleClicked.connect(self._open)
1370
+ layout.addWidget(self.list_widget)
1371
+ btn_layout = QHBoxLayout()
1372
+ del_btn = QPushButton("Delete")
1373
+ del_btn.clicked.connect(self._delete)
1374
+ open_btn = QPushButton("Open")
1375
+ open_btn.clicked.connect(lambda: self._open(self.list_widget.currentItem()))
1376
+ close_btn = QPushButton("Close")
1377
+ close_btn.clicked.connect(self.reject)
1378
+ btn_layout.addWidget(del_btn)
1379
+ btn_layout.addStretch()
1380
+ btn_layout.addWidget(open_btn)
1381
+ btn_layout.addWidget(close_btn)
1382
+ layout.addLayout(btn_layout)
1383
+
1384
+ def _open(self, item):
1385
+ if not item:
1386
+ return
1387
+ bm_id = item.data(Qt.ItemDataRole.UserRole)
1388
+ bm = next((b for b in self.state.bookmarks if b.id == bm_id), None)
1389
+ if bm:
1390
+ self.bookmark_opened.emit(bm.url)
1391
+
1392
+ def _delete(self):
1393
+ item = self.list_widget.currentItem()
1394
+ if not item:
1395
+ return
1396
+ bm_id = item.data(Qt.ItemDataRole.UserRole)
1397
+ self.state.remove_bookmark(bm_id)
1398
+ self.list_widget.takeItem(self.list_widget.row(item))
1399
+
1400
+
1401
+ class DownloadsDialog(QDialog):
1402
+ def __init__(self, state: BrowserState, parent=None):
1403
+ super().__init__(parent)
1404
+ self.state = state
1405
+ c = _cat_theme_colors()
1406
+ self.setWindowTitle("Downloads — CAT Browser")
1407
+ self.resize(600, 400)
1408
+ self.setStyleSheet(f"""
1409
+ QDialog {{ background: {c['bg']}; color: {c['text']}; }}
1410
+ QLabel {{ color: {c['text']}; font-size: 14px; font-weight: bold; }}
1411
+ QListWidget {{
1412
+ background: {c['surface']}; color: {c['text']};
1413
+ border-top: 1.5px solid {c['sh']}; border-left: 1.5px solid {c['sh']};
1414
+ border-right: 1px solid {c['hi']}; border-bottom: 1px solid {c['hi']};
1415
+ border-radius: 8px; padding: 4px; font-size: 12px;
1416
+ }}
1417
+ QListWidget::item {{ padding: 6px 8px; border-radius: 4px; }}
1418
+ QPushButton {{
1419
+ background: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 {c['surface_hover']}, stop:1 {c['surface']});
1420
+ color: {c['text']};
1421
+ border-top: 1px solid {c['hi']}; border-left: 1px solid {c['hi']};
1422
+ border-right: 1px solid {c['sh']}; border-bottom: 1px solid {c['sh']};
1423
+ border-radius: 6px; padding: 6px 16px; font-weight: 500;
1424
+ }}
1425
+ QPushButton:hover {{ border-color: {c['accent']}; }}
1426
+ QPushButton:pressed {{
1427
+ border-top: 1px solid {c['sh']}; border-left: 1px solid {c['sh']};
1428
+ border-right: 1px solid {c['hi']}; border-bottom: 1px solid {c['hi']};
1429
+ padding-top: 7px; padding-left: 17px;
1430
+ }}
1431
+ QPushButton:focus {{ border: 2px solid {c['focus_ring']}; }}
1432
+ """)
1433
+ layout = QVBoxLayout(self)
1434
+ layout.addWidget(QLabel("Downloads"))
1435
+ self.list_widget = QListWidget()
1436
+ for d in state.downloads:
1437
+ display = _friendly_url(d.url) if "localhost" in d.url else d.url[:50]
1438
+ item = QListWidgetItem(
1439
+ f"{d.filename} | {d.status} {d.progress}% | {display}"
1440
+ )
1441
+ self.list_widget.addItem(item)
1442
+ layout.addWidget(self.list_widget)
1443
+ btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
1444
+ btns.rejected.connect(self.reject)
1445
+ layout.addWidget(btns)
1446
+
1447
+
1448
+ class DevToolsDialog(QDialog):
1449
+ def __init__(self, parent=None):
1450
+ super().__init__(parent)
1451
+ c = _cat_theme_colors()
1452
+ self.setWindowTitle("Developer Tools — CAT Browser")
1453
+ self.resize(700, 500)
1454
+ self.setStyleSheet(f"""
1455
+ QDialog {{ background: {c['bg']}; color: {c['text']}; }}
1456
+ QLabel {{ color: {c['text']}; font-size: 13px; }}
1457
+ QPushButton {{
1458
+ background: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 {c['surface_hover']}, stop:1 {c['surface']});
1459
+ color: {c['text']};
1460
+ border-top: 1px solid {c['hi']}; border-left: 1px solid {c['hi']};
1461
+ border-right: 1px solid {c['sh']}; border-bottom: 1px solid {c['sh']};
1462
+ border-radius: 6px; padding: 6px 16px; font-weight: 500;
1463
+ }}
1464
+ QPushButton:hover {{ border-color: {c['accent']}; }}
1465
+ QPushButton:pressed {{
1466
+ border-top: 1px solid {c['sh']}; border-left: 1px solid {c['sh']};
1467
+ border-right: 1px solid {c['hi']}; border-bottom: 1px solid {c['hi']};
1468
+ padding-top: 7px; padding-left: 17px;
1469
+ }}
1470
+ QPushButton:focus {{ border: 2px solid {c['focus_ring']}; }}
1471
+ """)
1472
+ layout = QVBoxLayout(self)
1473
+ layout.addWidget(QLabel("Developer Tools"))
1474
+ layout.addWidget(QLabel("Console — browser logs appear here."))
1475
+ layout.addWidget(QLabel("Network — requests, WebSocket, server logs"))
1476
+ layout.addWidget(QLabel("Storage: ~/.cat_browser_*.json"))
1477
+ btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
1478
+ btns.rejected.connect(self.reject)
1479
+ layout.addWidget(btns)
1480
+
1481
+
1482
+ def launch_qt_browser(start_url: str = "about:home") -> int:
1483
+ # Performance: keep GPU enabled for smooth rendering
1484
+ os.environ.setdefault("QTWEBENGINE_CHROMIUM_FLAGS",
1485
+ "--disable-dev-shm-usage --enable-gpu-rasterization "
1486
+ "--ignore-gpu-blocklist --enable-zero-copy "
1487
+ "--disable-background-networking --disable-default-apps")
1488
+ os.environ.setdefault("QTWEBENGINE_DISABLE_SANDBOX", "1")
1489
+ app = QApplication.instance()
1490
+ created = False
1491
+ if app is None:
1492
+ app = QApplication(sys.argv)
1493
+ created = True
1494
+ if not start_url or start_url == "about:home":
1495
+ start_url = "about:home"
1496
+ w = CATBrowserWindow(start_url)
1497
+ w.show()
1498
+ if created:
1499
+ return app.exec()
1500
+ else:
1501
+ return 0