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,114 @@
1
+ """
2
+ CAT Input Architecture — pointer.py
3
+ Touch hit-target expansion and pointer adaptation.
4
+
5
+ Expands practical touch target zones for:
6
+ - 1-column pane resizers (ExplorerResizeHandle, RightPaneResizeHandle, ComposerResizeHandle)
7
+ - Compact toolbar glyphs (▷ Run, ⿻ Fullscreen, ⏻ Stop, ✕ Close)
8
+ Without changing their sleek visual appearance.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Optional, Tuple
14
+ from textual.widget import Widget
15
+ from textual.app import App
16
+
17
+
18
+ RESIZER_TOUCH_PADDING = 2 # Extra cells on each side for touch dragging
19
+
20
+
21
+ class TouchHitZone:
22
+ """Provides hit zone expansion for narrow or small interactive controls."""
23
+
24
+ @classmethod
25
+ def find_nearby_resizer(cls, app: App, screen_x: int, screen_y: int, tolerance: int = RESIZER_TOUCH_PADDING) -> Optional[Widget]:
26
+ """
27
+ Check if a touch coordinate landed within tolerance cells of any resizer handle.
28
+ Allows touching near a 1-column splitter to drag it naturally.
29
+ Guarantees that interactive inputs, buttons, and composer elements are never hijacked.
30
+ """
31
+ try:
32
+ screen = getattr(app, "screen", None)
33
+ if screen is None:
34
+ return None
35
+
36
+ # 1. Guard against redirecting clicks on interactive controls
37
+ widget_at = None
38
+ if hasattr(screen, "get_widget_at"):
39
+ try:
40
+ widget_at, _ = screen.get_widget_at(screen_x, screen_y)
41
+ except Exception:
42
+ widget_at = None
43
+
44
+ if widget_at is not None:
45
+ # Direct click on a resizer itself is allowed
46
+ if getattr(widget_at, "id", None) in ("cct-explorer-resizer", "cct-rightpane-resizer", "cct-composer-resizer"):
47
+ return widget_at
48
+
49
+ # Never hijack clicks intended for text inputs, buttons, switches, or trees
50
+ from textual.widgets import Input, TextArea, Button, OptionList, Select, Switch, Tree
51
+ if isinstance(widget_at, (Input, TextArea, Button, OptionList, Select, Switch, Tree)):
52
+ return None
53
+
54
+ # Never hijack clicks inside the composer card (editor stack, prompt row, input, footer, etc.)
55
+ ancestors = [widget_at] + list(getattr(widget_at, "ancestors", []))
56
+ for a in ancestors:
57
+ aid = getattr(a, "id", None)
58
+ if aid in ("cct-composer", "cct-editor-stack", "cct-prompt-row", "cct-input"):
59
+ return None
60
+
61
+ # 2. Resizers to inspect with directional bounds
62
+ for resizer_id in ("cct-explorer-resizer", "cct-rightpane-resizer", "cct-composer-resizer"):
63
+ resizers = screen.query(f"#{resizer_id}")
64
+ for resizer in resizers:
65
+ if not resizer.is_attached or not resizer.visible:
66
+ continue
67
+ region = resizer.region
68
+
69
+ if resizer_id == "cct-composer-resizer":
70
+ # Horizontal splitter above composer:
71
+ # Only expand UPWARD into conversation history (range: region.y - tolerance to region.y + region.height)
72
+ # NEVER expand downward into the composer card (y >= region.y + region.height)
73
+ expanded_x = range(region.x, region.x + region.width)
74
+ expanded_y = range(region.y - tolerance, region.y + region.height)
75
+ else:
76
+ # Vertical splitters (explorer, rightpane):
77
+ # Expand horizontally in X, keep within Y bounds
78
+ expanded_x = range(region.x - tolerance, region.x + region.width + tolerance)
79
+ expanded_y = range(region.y, region.y + region.height)
80
+
81
+ if screen_x in expanded_x and screen_y in expanded_y:
82
+ return resizer
83
+ except Exception:
84
+ pass
85
+ return None
86
+
87
+ @classmethod
88
+ def find_nearby_toolbar_control(cls, app: App, screen_x: int, screen_y: int, tolerance: int = 1) -> Optional[Widget]:
89
+ """
90
+ Check if a touch coordinate landed near compact controls (▷, ⿻, ⏻, ✕).
91
+ """
92
+ try:
93
+ screen = getattr(app, "screen", None)
94
+ if screen is None:
95
+ return None
96
+
97
+ control_ids = (
98
+ "cct-tb-run", "cct-tb-split", "cct-tb-close", "cct-tb-preview",
99
+ "cct-bv-split", "cct-bv-close", "cct-bv-fullscreen", "cct-bv-reload",
100
+ "btn-send", "btn-attach", "btn-permissions"
101
+ )
102
+ for cid in control_ids:
103
+ matches = screen.query(f"#{cid}")
104
+ for ctrl in matches:
105
+ if not ctrl.is_attached or not ctrl.visible:
106
+ continue
107
+ region = ctrl.region
108
+ expanded_x = range(region.x - tolerance, region.x + region.width + tolerance)
109
+ expanded_y = range(region.y - tolerance, region.y + region.height + tolerance)
110
+ if screen_x in expanded_x and screen_y in expanded_y:
111
+ return ctrl
112
+ except Exception:
113
+ pass
114
+ return None
@@ -0,0 +1,345 @@
1
+ """
2
+ CAT Input Architecture — touch.py
3
+ High-fidelity touch interaction handling for terminal applications.
4
+
5
+ Implements:
6
+ 1. TouchTapRecognizer:
7
+ - Fuzzy coordinate matching (2-cell tolerance) solving Textual's click-drop bug.
8
+ - Double-tap detection with relaxed offset tolerance.
9
+ - Long-press recognition for touch context actions.
10
+ 2. TouchScrollHandler:
11
+ - Natural finger drag-to-scroll across all containers (chat, file tree, editor, modals, settings, model list).
12
+ - Click suppression during drag-scrolling so lifting a finger never causes accidental clicks.
13
+ - Kinetic flick/momentum scrolling with smooth deceleration.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import time
19
+ import math
20
+ from typing import Optional, Tuple, Any, Callable
21
+ from textual import events
22
+ from textual.widget import Widget
23
+ from textual.containers import ScrollableContainer, VerticalScroll
24
+ from textual.scroll_view import ScrollView
25
+
26
+ # Tolerances & Thresholds
27
+ TAP_MAX_DISTANCE = 2.0 # Manhattan distance in cells (finger jitter allowance)
28
+ TAP_MAX_DURATION = 0.45 # Max seconds between down and up for a single tap
29
+ DOUBLE_TAP_MAX_DELAY = 0.40 # Max seconds between taps for double-tap
30
+ DOUBLE_TAP_MAX_DISTANCE = 3.0 # Max distance between consecutive taps
31
+ LONG_PRESS_DURATION = 0.55 # Seconds required to trigger long-press
32
+ SCROLL_DRAG_THRESHOLD = 2.5 # Movement cells required to switch into scroll mode
33
+
34
+
35
+ class TouchTapRecognizer:
36
+ """Detects clean taps, double-taps, and long-presses despite finger jitter."""
37
+
38
+ def __init__(self, on_tap: Optional[Callable[[Widget, events.MouseUp, int], None]] = None,
39
+ on_long_press: Optional[Callable[[Widget, Tuple[int, int]], None]] = None):
40
+ self.on_tap = on_tap
41
+ self.on_long_press = on_long_press
42
+
43
+ self._active = False
44
+ self._down_time = 0.0
45
+ self._down_pos: Tuple[int, int] = (0, 0)
46
+ self._down_screen_pos: Tuple[int, int] = (0, 0)
47
+ self._down_widget: Optional[Widget] = None
48
+
49
+ # Double-click / chained tap state
50
+ self._last_tap_time = 0.0
51
+ self._last_tap_pos: Tuple[int, int] = (0, 0)
52
+ self._last_tap_widget: Optional[Widget] = None
53
+ self._chain = 1
54
+
55
+ # Long-press timer tracking
56
+ self._long_press_triggered = False
57
+
58
+ def on_mouse_down(self, widget: Optional[Widget], event: events.MouseDown) -> None:
59
+ """Handle finger contact."""
60
+ if getattr(event, "button", 1) != 1:
61
+ return
62
+ now = time.monotonic()
63
+ self._active = True
64
+ self._down_time = now
65
+ self._down_pos = (event.x, event.y)
66
+ self._down_screen_pos = (getattr(event, "screen_x", event.x), getattr(event, "screen_y", event.y))
67
+ self._down_widget = widget
68
+ self._long_press_triggered = False
69
+
70
+ def on_mouse_move(self, event: events.MouseMove) -> bool:
71
+ """Check if movement exceeds tap jitter threshold."""
72
+ if not self._active:
73
+ return False
74
+ cur_screen = (getattr(event, "screen_x", event.x), getattr(event, "screen_y", event.y))
75
+ dx = abs(cur_screen[0] - self._down_screen_pos[0])
76
+ dy = abs(cur_screen[1] - self._down_screen_pos[1])
77
+ if dx + dy > TAP_MAX_DISTANCE:
78
+ # Finger moved beyond tap threshold — no longer a tap
79
+ self._active = False
80
+ return False
81
+ return True
82
+
83
+ def check_long_press(self) -> bool:
84
+ """Poll or timer check for long-press trigger."""
85
+ if not self._active or self._long_press_triggered or self._down_widget is None:
86
+ return False
87
+ now = time.monotonic()
88
+ if now - self._down_time >= LONG_PRESS_DURATION:
89
+ self._long_press_triggered = True
90
+ self._active = False # Suppress normal tap on release
91
+ if self.on_long_press and self._down_widget is not None:
92
+ try:
93
+ self.on_long_press(self._down_widget, self._down_screen_pos)
94
+ except Exception:
95
+ pass
96
+ return True
97
+ return False
98
+
99
+ def on_mouse_up(self, up_widget: Optional[Widget], event: events.MouseUp) -> Tuple[bool, int, Optional[Widget]]:
100
+ """
101
+ Handle finger lift.
102
+ Returns: (is_tap, click_chain, resolved_target_widget)
103
+ """
104
+ if not self._active or self._long_press_triggered:
105
+ self._active = False
106
+ return False, 1, None
107
+
108
+ self._active = False
109
+ now = time.monotonic()
110
+ duration = now - self._down_time
111
+
112
+ if duration > TAP_MAX_DURATION:
113
+ return False, 1, None
114
+
115
+ cur_screen = (getattr(event, "screen_x", event.x), getattr(event, "screen_y", event.y))
116
+ dx = abs(cur_screen[0] - self._down_screen_pos[0])
117
+ dy = abs(cur_screen[1] - self._down_screen_pos[1])
118
+
119
+ if dx + dy > TAP_MAX_DISTANCE:
120
+ return False, 1, None
121
+
122
+ # Resolve best target widget:
123
+ # If up_widget is down_widget, exact match.
124
+ # Otherwise, check if down_widget is an interactive widget or descendant/ancestor
125
+ target = self._resolve_target(self._down_widget, up_widget)
126
+ if target is None:
127
+ return False, 1, None
128
+
129
+ # Determine chain (double-tap)
130
+ dist_from_last = (abs(cur_screen[0] - self._last_tap_pos[0]) +
131
+ abs(cur_screen[1] - self._last_tap_pos[1]))
132
+ if (now - self._last_tap_time <= DOUBLE_TAP_MAX_DELAY and
133
+ dist_from_last <= DOUBLE_TAP_MAX_DISTANCE and
134
+ (self._last_tap_widget is target or self._is_related(self._last_tap_widget, target))):
135
+ self._chain += 1
136
+ else:
137
+ self._chain = 1
138
+
139
+ self._last_tap_time = now
140
+ self._last_tap_pos = cur_screen
141
+ self._last_tap_widget = target
142
+
143
+ if self.on_tap:
144
+ try:
145
+ self.on_tap(target, event, self._chain)
146
+ except Exception:
147
+ pass
148
+
149
+ return True, self._chain, target
150
+
151
+ def cancel(self) -> None:
152
+ """Cancel tap recognition (e.g. when drag-to-scroll takes over)."""
153
+ self._active = False
154
+ self._long_press_triggered = False
155
+
156
+ @staticmethod
157
+ def _is_related(w1: Optional[Widget], w2: Optional[Widget]) -> bool:
158
+ if w1 is None or w2 is None:
159
+ return False
160
+ if w1 is w2:
161
+ return True
162
+ # Check parent hierarchy
163
+ cur = w1
164
+ while cur is not None:
165
+ if cur is w2:
166
+ return True
167
+ cur = getattr(cur, "parent", None)
168
+ cur = w2
169
+ while cur is not None:
170
+ if cur is w1:
171
+ return True
172
+ cur = getattr(cur, "parent", None)
173
+ return False
174
+
175
+ @classmethod
176
+ def _resolve_target(cls, down_w: Optional[Widget], up_w: Optional[Widget]) -> Optional[Widget]:
177
+ if down_w is None:
178
+ return up_w
179
+ if up_w is None:
180
+ return down_w
181
+ if down_w is up_w:
182
+ return down_w
183
+
184
+ # If one is ancestor of the other, favor the interactive child or button
185
+ from textual.widgets import Button, Tab, Tree
186
+
187
+ interactive_types = (Button, Tab)
188
+
189
+ if isinstance(down_w, interactive_types):
190
+ return down_w
191
+ if isinstance(up_w, interactive_types):
192
+ return up_w
193
+
194
+ # Check if down_widget is inside a Button or interactive control
195
+ cur = down_w
196
+ while cur is not None:
197
+ if isinstance(cur, interactive_types) or getattr(cur, "can_focus", False):
198
+ return cur
199
+ cur = getattr(cur, "parent", None)
200
+
201
+ cur = up_w
202
+ while cur is not None:
203
+ if isinstance(cur, interactive_types) or getattr(cur, "can_focus", False):
204
+ return cur
205
+ cur = getattr(cur, "parent", None)
206
+
207
+ # If they share a direct parent container, down_w is what the user touched first
208
+ return down_w
209
+
210
+
211
+ class TouchScrollHandler:
212
+ """Manages finger drag-to-scroll with kinetic momentum across any scroll container."""
213
+
214
+ def __init__(self):
215
+ self._is_scrolling = False
216
+ self._container: Optional[Widget] = None
217
+ self._down_screen_y = 0.0
218
+ self._down_screen_x = 0.0
219
+ self._last_move_y = 0.0
220
+ self._last_move_time = 0.0
221
+ self._initial_scroll_y = 0.0
222
+ self._velocity_y = 0.0 # cells per second
223
+
224
+ @property
225
+ def is_scrolling(self) -> bool:
226
+ return self._is_scrolling
227
+
228
+ def on_mouse_down(self, target_widget: Optional[Widget], event: events.MouseDown) -> bool:
229
+ """Start tracking potential touch drag."""
230
+ if getattr(event, "button", 1) != 1:
231
+ return False
232
+
233
+ self._container = self._find_scroll_container(target_widget)
234
+ self._is_scrolling = False
235
+ self._down_screen_y = float(getattr(event, "screen_y", event.y))
236
+ self._down_screen_x = float(getattr(event, "screen_x", event.x))
237
+ self._last_move_y = self._down_screen_y
238
+ self._last_move_time = time.monotonic()
239
+ self._velocity_y = 0.0
240
+
241
+ if self._container is not None:
242
+ self._initial_scroll_y = float(getattr(self._container, "scroll_y", 0.0))
243
+ return True
244
+ return False
245
+
246
+ def on_mouse_move(self, event: events.MouseMove) -> bool:
247
+ """Handle finger movement and perform drag scrolling."""
248
+ if self._container is None:
249
+ return False
250
+
251
+ now = time.monotonic()
252
+ cur_y = float(getattr(event, "screen_y", event.y))
253
+ dt = max(0.001, now - self._last_move_time)
254
+
255
+ dy_total = cur_y - self._down_screen_y
256
+ step_dy = cur_y - self._last_move_y
257
+
258
+ if not self._is_scrolling:
259
+ # Check threshold to enter drag-scroll mode
260
+ if abs(dy_total) >= SCROLL_DRAG_THRESHOLD:
261
+ self._is_scrolling = True
262
+
263
+ if self._is_scrolling:
264
+ # Invert delta: dragging finger UP scrolls content DOWN
265
+ scroll_delta = -step_dy
266
+ self._apply_scroll(self._container, scroll_delta)
267
+
268
+ # Update velocity moving average
269
+ instant_velocity = -step_dy / dt
270
+ self._velocity_y = 0.6 * self._velocity_y + 0.4 * instant_velocity
271
+
272
+ self._last_move_y = cur_y
273
+ self._last_move_time = now
274
+ return True
275
+
276
+ return False
277
+
278
+ def on_mouse_up(self, event: events.MouseUp) -> bool:
279
+ """Finish drag-scroll and trigger flick momentum if applicable."""
280
+ was_scrolling = self._is_scrolling
281
+ container = self._container
282
+
283
+ self._is_scrolling = False
284
+ self._container = None
285
+
286
+ if was_scrolling and container is not None:
287
+ # Apply momentum if released with velocity (> 8 cells/sec)
288
+ if abs(self._velocity_y) > 8.0:
289
+ self._apply_momentum(container, self._velocity_y)
290
+ return True
291
+
292
+ return False
293
+
294
+ @classmethod
295
+ def _find_scroll_container(cls, start_widget: Optional[Widget]) -> Optional[Widget]:
296
+ """Find the nearest scrollable ancestor with actual overflowing content."""
297
+ if start_widget is None:
298
+ return None
299
+
300
+ # Interactive controls must never trigger drag-scrolling
301
+ from textual.widgets import Button, Input, TextArea, Tab, Select, OptionList, Checkbox, RadioSet, Switch
302
+ interactive_types = (Button, Input, TextArea, Tab, Select, OptionList, Checkbox, RadioSet, Switch)
303
+
304
+ # Check if the touched widget or its immediate ancestors are interactive
305
+ check_node = start_widget
306
+ for _ in range(3):
307
+ if check_node is None:
308
+ break
309
+ if isinstance(check_node, (ScrollableContainer, VerticalScroll, ScrollView)):
310
+ break
311
+ if isinstance(check_node, interactive_types) or getattr(check_node, "can_focus", False):
312
+ return None
313
+ check_node = getattr(check_node, "parent", None)
314
+
315
+ cur = start_widget
316
+ while cur is not None:
317
+ # Check for scrollable containers that actually have scrollable content
318
+ if isinstance(cur, (ScrollableContainer, VerticalScroll, ScrollView)):
319
+ if getattr(cur, "max_scroll_y", 0) > 0:
320
+ return cur
321
+ cur = getattr(cur, "parent", None)
322
+ return None
323
+
324
+ @classmethod
325
+ def _apply_scroll(cls, container: Widget, delta_y: float) -> None:
326
+ """Apply vertical scroll offset."""
327
+ try:
328
+ if hasattr(container, "scroll_relative"):
329
+ container.scroll_relative(y=delta_y, animate=False)
330
+ elif hasattr(container, "scroll_to"):
331
+ new_y = max(0.0, getattr(container, "scroll_y", 0.0) + delta_y)
332
+ container.scroll_to(y=new_y, animate=False)
333
+ except Exception:
334
+ pass
335
+
336
+ @classmethod
337
+ def _apply_momentum(cls, container: Widget, initial_velocity: float) -> None:
338
+ """Apply smooth kinetic flick decay."""
339
+ try:
340
+ # Cap momentum travel to prevent runaway scrolls
341
+ travel = math.copysign(min(18.0, abs(initial_velocity) * 0.18), initial_velocity)
342
+ if hasattr(container, "scroll_relative"):
343
+ container.scroll_relative(y=travel, animate=True, duration=0.22, easing="out_cubic")
344
+ except Exception:
345
+ pass
calc_terminal/keys.py ADDED
@@ -0,0 +1,84 @@
1
+ """
2
+ Cross-platform single-keypress reader, used to drive shortcut keys
3
+ inside the live simulations (atom viewer, orbital viewer, graphs)
4
+ without needing to press Enter.
5
+
6
+ Falls back gracefully to blocking input() on platforms/terminals where
7
+ raw mode isn't available (e.g. some CI pipes) so the app never crashes.
8
+ """
9
+
10
+ import sys
11
+ import select
12
+
13
+
14
+ def stdin_is_interactive():
15
+ """True if stdin is a real attached terminal that can deliver raw
16
+ keypresses. False for pipes, redirected files, and other
17
+ non-interactive contexts (CI runners, containers without -it,
18
+ scripted input, etc).
19
+
20
+ This matters because select.select() reports a pipe at EOF as
21
+ 'readable' forever, and termios/msvcrt both fail silently on a
22
+ non-tty fd. Without this check, a live view driven by
23
+ key_available()/read_key() spins in a tight busy loop forever,
24
+ burning CPU and never exiting, since it can never actually detect a
25
+ 'q' keypress that will never arrive."""
26
+ try:
27
+ return sys.stdin.isatty()
28
+ except Exception:
29
+ return False
30
+
31
+
32
+ def key_available():
33
+ """Non-blocking check: is a key waiting in stdin? Unix only; on
34
+ platforms without select-on-stdin support this always returns False
35
+ (the caller should just keep animating on a timer). Always False
36
+ when stdin isn't a real interactive terminal — see
37
+ stdin_is_interactive() for why."""
38
+ if not stdin_is_interactive():
39
+ return False
40
+ try:
41
+ r, _, _ = select.select([sys.stdin], [], [], 0)
42
+ return bool(r)
43
+ except Exception:
44
+ return False
45
+
46
+
47
+ def read_key():
48
+ """Read exactly one keypress, no Enter required. Returns '' if no
49
+ raw-mode backend is available."""
50
+ try:
51
+ import termios
52
+ import tty
53
+ fd = sys.stdin.fileno()
54
+ old = termios.tcgetattr(fd)
55
+ try:
56
+ tty.setcbreak(fd)
57
+ ch = sys.stdin.read(1)
58
+ if ch == '\x1b': # potential escape sequence
59
+ # peek if more chars are coming (non-blocking)
60
+ r, _, _ = select.select([sys.stdin], [], [], 0.01)
61
+ if r:
62
+ ch += sys.stdin.read(2) # read [A, [B, etc.
63
+ finally:
64
+ termios.tcsetattr(fd, termios.TCSADRAIN, old)
65
+ return ch
66
+ except Exception:
67
+ pass
68
+ try:
69
+ import msvcrt
70
+ if msvcrt.kbhit():
71
+ return msvcrt.getch().decode(errors="ignore")
72
+ return ""
73
+ except Exception:
74
+ return ""
75
+
76
+
77
+ SHORTCUTS_HELP = [
78
+ ("q", "quit the live view / return to menu"),
79
+ ("space", "pause / resume animation"),
80
+ ("+ / -", "speed up / slow down"),
81
+ ("n / p", "next / previous element or state"),
82
+ ("r", "reset / randomize"),
83
+ ("s", "save a snapshot (2D/3D image export)"),
84
+ ]