meltygui 0.1.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 (372) hide show
  1. meltygui/__init__.py +107 -0
  2. meltygui/accounts/__init__.py +0 -0
  3. meltygui/accounts/internet_accounts.py +1355 -0
  4. meltygui/chat/__init__.py +91 -0
  5. meltygui/chat/activity.py +75 -0
  6. meltygui/chat/backends.py +36 -0
  7. meltygui/chat/chat_interface.py +732 -0
  8. meltygui/chat/chat_proxy.py +352 -0
  9. meltygui/chat/codex_proxy.py +592 -0
  10. meltygui/chat/codex_settings.py +100 -0
  11. meltygui/chat/codex_transport.py +60 -0
  12. meltygui/chat/command_parser.py +204 -0
  13. meltygui/chat/images.py +227 -0
  14. meltygui/chat/messages.py +417 -0
  15. meltygui/chat/metadata.py +139 -0
  16. meltygui/chat/writer_locks.py +64 -0
  17. meltygui/code/__init__.py +0 -0
  18. meltygui/code/basic_converters.py +533 -0
  19. meltygui/code/chain_converters.py +2111 -0
  20. meltygui/code/code_checks.py +2209 -0
  21. meltygui/code/core_syntax.py +1430 -0
  22. meltygui/code/file_converters.py +1933 -0
  23. meltygui/code/fileref.py +702 -0
  24. meltygui/code/hotswap_guard.py +144 -0
  25. meltygui/code/libcst_conversion.py +9724 -0
  26. meltygui/code/live_instrument.py +392 -0
  27. meltygui/code/live_view.py +2490 -0
  28. meltygui/code/melty_scan.py +2684 -0
  29. meltygui/code/new_codecs.py +1255 -0
  30. meltygui/code/new_converters.py +3017 -0
  31. meltygui/code/project_code.py +278 -0
  32. meltygui/code/source_context.py +63 -0
  33. meltygui/code/symbol_roster.py +1588 -0
  34. meltygui/code/syntax_check.py +34 -0
  35. meltygui/code/syntax_check_worker.py +114 -0
  36. meltygui/completion/__init__.py +0 -0
  37. meltygui/completion/fim.py +1232 -0
  38. meltygui/completion/fim_context.py +481 -0
  39. meltygui/completion/providers/__init__.py +0 -0
  40. meltygui/completion/providers/anthropic_oauth.py +446 -0
  41. meltygui/completion/providers/anthropic_requests.py +69 -0
  42. meltygui/completion/providers/claude.py +203 -0
  43. meltygui/completion/providers/claude_usage.py +499 -0
  44. meltygui/completion/providers/codex_accounts.py +180 -0
  45. meltygui/completion/providers/copilot.py +617 -0
  46. meltygui/completion/providers/oauth_popup.py +220 -0
  47. meltygui/completion/providers/ollama.py +320 -0
  48. meltygui/completion/providers/profiles.py +23 -0
  49. meltygui/core/README.md +88 -0
  50. meltygui/core/__init__.py +1 -0
  51. meltygui/core/automation/__init__.py +1 -0
  52. meltygui/core/automation/action_core.py +153 -0
  53. meltygui/core/automation/collection_action.py +45 -0
  54. meltygui/core/automation/mcp_eval.py +167 -0
  55. meltygui/core/automation/mcp_hotswap.py +107 -0
  56. meltygui/core/automation/mcp_query.py +552 -0
  57. meltygui/core/automation/mcp_server.py +657 -0
  58. meltygui/core/automation/orchestration_core.py +2636 -0
  59. meltygui/core/automation/query_core.py +124 -0
  60. meltygui/core/automation/search_core.py +26 -0
  61. meltygui/core/automation/selector_core.py +340 -0
  62. meltygui/core/automation/value_core.py +1752 -0
  63. meltygui/core/cache/__init__.py +1 -0
  64. meltygui/core/cache/cache_diagnostics.py +0 -0
  65. meltygui/core/cache/invalidation_decoration.py +153 -0
  66. meltygui/core/cache/invalidation_tracker.py +43 -0
  67. meltygui/core/cache/tile_cache.py +5968 -0
  68. meltygui/core/conversion/__init__.py +1 -0
  69. meltygui/core/conversion/bubbling.py +599 -0
  70. meltygui/core/conversion/cache_tree.py +188 -0
  71. meltygui/core/conversion/chain.py +113 -0
  72. meltygui/core/conversion/converter_register.py +145 -0
  73. meltygui/core/conversion/data_decoration.py +67 -0
  74. meltygui/core/conversion/dict_conversion.py +1815 -0
  75. meltygui/core/conversion/dict_conversion_util.py +177 -0
  76. meltygui/core/conversion/dynamic_obj.py +89 -0
  77. meltygui/core/conversion/graph_compare.py +183 -0
  78. meltygui/core/conversion/load_save_v2.py +1032 -0
  79. meltygui/core/conversion/missing_saved_class.py +39 -0
  80. meltygui/core/conversion/path_finder.py +606 -0
  81. meltygui/core/conversion/render_host.py +1083 -0
  82. meltygui/core/core_render.py +6537 -0
  83. meltygui/core/definition_hotswap.py +231 -0
  84. meltygui/core/diagnostics/__init__.py +1 -0
  85. meltygui/core/diagnostics/attribute_churn.py +38 -0
  86. meltygui/core/diagnostics/fps_counter.py +39 -0
  87. meltygui/core/diagnostics/gpu_frame_timer.py +103 -0
  88. meltygui/core/diagnostics/inspection_core.py +169 -0
  89. meltygui/core/diagnostics/monitor_core.py +105 -0
  90. meltygui/core/diagnostics/notifications.py +706 -0
  91. meltygui/core/diagnostics/perf_trace.py +281 -0
  92. meltygui/core/diagnostics/profile_decoration.py +88 -0
  93. meltygui/core/diagnostics/resize_trace.py +62 -0
  94. meltygui/core/diagnostics/screenshot_core.py +247 -0
  95. meltygui/core/diagnostics/session_status.py +98 -0
  96. meltygui/core/diagnostics/trace_core.py +445 -0
  97. meltygui/core/files/__init__.py +1 -0
  98. meltygui/core/files/file_core.py +208 -0
  99. meltygui/core/files/file_explorer_core.py +104 -0
  100. meltygui/core/files/file_tree_core.py +198 -0
  101. meltygui/core/files/file_watch_core.py +43 -0
  102. meltygui/core/files/import_graph_core.py +43 -0
  103. meltygui/core/files/metadata_core.py +51 -0
  104. meltygui/core/graphics/__init__.py +1 -0
  105. meltygui/core/graphics/cuda_context_core.py +166 -0
  106. meltygui/core/graphics/cuda_interop_core.py +136 -0
  107. meltygui/core/graphics/cuda_kernel_core.py +91 -0
  108. meltygui/core/graphics/framebuffer_recorder.py +337 -0
  109. meltygui/core/graphics/gl_state.py +658 -0
  110. meltygui/core/graphics/lut_core.py +52 -0
  111. meltygui/core/graphics/overlay_renderer.py +984 -0
  112. meltygui/core/graphics/scene_target.py +180 -0
  113. meltygui/core/graphics/screenshot.py +439 -0
  114. meltygui/core/graphics/shader_func.py +478 -0
  115. meltygui/core/graphics/tensor_core.py +45 -0
  116. meltygui/core/graphics/text_texture.py +329 -0
  117. meltygui/core/graphics/wayland_color.py +635 -0
  118. meltygui/core/input/__init__.py +1 -0
  119. meltygui/core/input/collision.py +165 -0
  120. meltygui/core/input/drag_drop_core.py +1525 -0
  121. meltygui/core/input/hypr_left_drag.py +323 -0
  122. meltygui/core/input/input_core.py +245 -0
  123. meltygui/core/input/input_handler.py +1101 -0
  124. meltygui/core/input/mouse_cursor.py +355 -0
  125. meltygui/core/input/pynput_backend.py +1054 -0
  126. meltygui/core/input/space_mouse.py +338 -0
  127. meltygui/core/input/touchpad_backend.py +393 -0
  128. meltygui/core/input/view_selection.py +177 -0
  129. meltygui/core/layout/__init__.py +1 -0
  130. meltygui/core/layout/column_core.py +2153 -0
  131. meltygui/core/layout/cursor_core.py +161 -0
  132. meltygui/core/layout/dropdown_core.py +278 -0
  133. meltygui/core/layout/edge_constraints.py +155 -0
  134. meltygui/core/layout/grid_core.py +117 -0
  135. meltygui/core/layout/header_core.py +23 -0
  136. meltygui/core/layout/header_runtime.py +67 -0
  137. meltygui/core/layout/layout_core.py +87 -0
  138. meltygui/core/layout/tile_manager_core.py +591 -0
  139. meltygui/core/melty.py +6726 -0
  140. meltygui/core/module_map.json +896 -0
  141. meltygui/core/module_names.py +19 -0
  142. meltygui/core/rendering/__init__.py +1 -0
  143. meltygui/core/rendering/core_decoration.py +431 -0
  144. meltygui/core/rendering/core_render_helpers.py +328 -0
  145. meltygui/core/rendering/func_metadata.py +398 -0
  146. meltygui/core/rendering/mode.py +818 -0
  147. meltygui/core/rendering/mode_defaults.py +41 -0
  148. meltygui/core/rendering/modes.py +136 -0
  149. meltygui/core/rendering/parameter_core.py +1665 -0
  150. meltygui/core/rendering/render_dispatch.py +1891 -0
  151. meltygui/core/rendering/render_funcs.py +273 -0
  152. meltygui/core/rendering/shaped.py +312 -0
  153. meltygui/core/rendering/window_decoration.py +25 -0
  154. meltygui/core/runtime/__init__.py +1 -0
  155. meltygui/core/runtime/app.py +735 -0
  156. meltygui/core/runtime/app_session.py +140 -0
  157. meltygui/core/runtime/background.py +564 -0
  158. meltygui/core/runtime/extensions.py +53 -0
  159. meltygui/core/runtime/gc_manager.py +1163 -0
  160. meltygui/core/runtime/lifecycle.py +21 -0
  161. meltygui/core/runtime/paths.py +27 -0
  162. meltygui/core/runtime/settings.py +14 -0
  163. meltygui/core/runtime/singleton.py +16 -0
  164. meltygui/core/runtime/thread_safe_bool.py +24 -0
  165. meltygui/core/runtime/thread_signal.py +30 -0
  166. meltygui/core/runtime/toggles.py +3115 -0
  167. meltygui/core/services/__init__.py +1 -0
  168. meltygui/core/services/account_core.py +10 -0
  169. meltygui/core/services/chat_core.py +19 -0
  170. meltygui/core/services/claude_terminal_core.py +346 -0
  171. meltygui/core/services/terminal_core.py +458 -0
  172. meltygui/core/services/terminal_runtime.py +78 -0
  173. meltygui/core/styling/__init__.py +1 -0
  174. meltygui/core/styling/color_core.py +46 -0
  175. meltygui/core/styling/fonts.py +639 -0
  176. meltygui/core/styling/global_style.py +338 -0
  177. meltygui/core/styling/style.py +198 -0
  178. meltygui/core/styling/style_core.py +522 -0
  179. meltygui/core/styling/warm_start.py +149 -0
  180. meltygui/core/windowing/__init__.py +1 -0
  181. meltygui/core/windowing/backends/PYIMGUI_LICENSE +28 -0
  182. meltygui/core/windowing/backends/__init__.py +1 -0
  183. meltygui/core/windowing/backends/imgui_renderer.py +138 -0
  184. meltygui/core/windowing/backends/native_wayland.py +850 -0
  185. meltygui/core/windowing/backends/protocols/xdg-decoration-unstable-v1.xml +156 -0
  186. meltygui/core/windowing/backends/protocols/xdg-shell.xml +1420 -0
  187. meltygui/core/windowing/backends/wayland_protocol.py +101 -0
  188. meltygui/core/windowing/dock_core.py +182 -0
  189. meltygui/core/windowing/frame_geometry.py +42 -0
  190. meltygui/core/windowing/geometry_feed.py +864 -0
  191. meltygui/core/windowing/glfw_utils.py +1343 -0
  192. meltygui/core/windowing/os_frame.py +1552 -0
  193. meltygui/core/windowing/surface.py +643 -0
  194. meltygui/core/windowing/titlebar.py +1560 -0
  195. meltygui/core/windowing/titlebar_buttons.py +281 -0
  196. meltygui/core/windowing/wayland_move.py +932 -0
  197. meltygui/core/windowing/window_api.py +62 -0
  198. meltygui/core/windowing/window_constants.py +339 -0
  199. meltygui/core/windowing/window_visibility.py +162 -0
  200. meltygui/debug/__init__.py +0 -0
  201. meltygui/debug/app_view_utils.py +9 -0
  202. meltygui/editor/__init__.py +0 -0
  203. meltygui/editor/bash_syntax.py +30 -0
  204. meltygui/editor/code_line_fast.py +152 -0
  205. meltygui/editor/diff.py +139 -0
  206. meltygui/editor/external_changes.py +159 -0
  207. meltygui/editor/file_header.py +41 -0
  208. meltygui/editor/live_usage.py +169 -0
  209. meltygui/editor/live_view_views.py +1803 -0
  210. meltygui/editor/pending_save.py +1351 -0
  211. meltygui/editor/roster_tints.py +547 -0
  212. meltygui/editor/source_preview.py +16 -0
  213. meltygui/editor/source_tools.py +10 -0
  214. meltygui/editor/source_ui.py +70 -0
  215. meltygui/editor/spell_check.py +77 -0
  216. meltygui/editor/text_editor.py +9076 -0
  217. meltygui/editor/usage_picker.py +538 -0
  218. meltygui/events/__init__.py +0 -0
  219. meltygui/events/example.py +118 -0
  220. meltygui/examples/__init__.py +0 -0
  221. meltygui/examples/columns_demo.py +33 -0
  222. meltygui/examples/columns_window_demo.py +60 -0
  223. meltygui/examples/context_menu_demo.py +24 -0
  224. meltygui/examples/context_menu_window_demo.py +49 -0
  225. meltygui/examples/gui_playground.py +127 -0
  226. meltygui/examples/live_view_playground.py +256 -0
  227. meltygui/examples/lora.py +43 -0
  228. meltygui/examples/lora_data.py +59 -0
  229. meltygui/examples/lora_policies.py +67 -0
  230. meltygui/examples/mode_demo.py +49 -0
  231. meltygui/examples/modifies_demo.py +119 -0
  232. meltygui/examples/scalar_policies.py +60 -0
  233. meltygui/examples/style_layouts.py +149 -0
  234. meltygui/examples/tile_manager_demo.py +74 -0
  235. meltygui/examples/tint_demo.py +160 -0
  236. meltygui/examples/tint_functions.py +56 -0
  237. meltygui/examples/trace_demo.py +88 -0
  238. meltygui/examples/two_windows.py +36 -0
  239. meltygui/files/__init__.py +0 -0
  240. meltygui/files/fast_file_explorer.py +439 -0
  241. meltygui/gnome_extension/lsd-window-geometry@latent-descent/extension.js +237 -0
  242. meltygui/gnome_extension/lsd-window-geometry@latent-descent/lsd-window-geometry@latent-descent.iml +9 -0
  243. meltygui/gnome_extension/lsd-window-geometry@latent-descent/metadata.json +7 -0
  244. meltygui/graphics/__init__.py +6 -0
  245. meltygui/graphics/base.py +85 -0
  246. meltygui/graphics/examples.py +507 -0
  247. meltygui/graphics/executor.py +520 -0
  248. meltygui/graphics/filter.py +667 -0
  249. meltygui/graphics/filter.pyi +856 -0
  250. meltygui/graphics/generate_stubs.py +22 -0
  251. meltygui/graphics/registry.py +203 -0
  252. meltygui/graphics/shader_compiler.py +155 -0
  253. meltygui/graphics/shaders.py +1302 -0
  254. meltygui/graphics/stub_generator.py +250 -0
  255. meltygui/graphics/texture_manager.py +170 -0
  256. meltygui/graphics/texture_min_max.py +295 -0
  257. meltygui/hdr_color.py +757 -0
  258. meltygui/image_load.py +308 -0
  259. meltygui/model/__init__.py +1 -0
  260. meltygui/model/account_model.py +142 -0
  261. meltygui/model/camera_model.py +159 -0
  262. meltygui/model/chat_model.py +86 -0
  263. meltygui/model/code_model.py +62 -0
  264. meltygui/model/code_proxy_model.py +876 -0
  265. meltygui/model/collection_model.py +41 -0
  266. meltygui/model/color_model.py +127 -0
  267. meltygui/model/cuda_tensor_model.py +36 -0
  268. meltygui/model/cuda_texture_model.py +149 -0
  269. meltygui/model/dropdown_model.py +137 -0
  270. meltygui/model/file_metadata_model.py +73 -0
  271. meltygui/model/file_model.py +289 -0
  272. meltygui/model/format_model.py +390 -0
  273. meltygui/model/graph_model.py +98 -0
  274. meltygui/model/icon_model.py +1024 -0
  275. meltygui/model/import_graph_model.py +468 -0
  276. meltygui/model/layout_model.py +15 -0
  277. meltygui/model/lut_model.py +266 -0
  278. meltygui/model/search_model.py +173 -0
  279. meltygui/model/tensor_model.py +402 -0
  280. meltygui/model/terminal_model.py +329 -0
  281. meltygui/model/texture_model.py +146 -0
  282. meltygui/model/tile_model.py +24 -0
  283. meltygui/model/trace_model.py +198 -0
  284. meltygui/model/trace_report_model.py +146 -0
  285. meltygui/models/__init__.py +0 -0
  286. meltygui/models/file_meta.py +613 -0
  287. meltygui/models/function_console.py +184 -0
  288. meltygui/models/orchestration.py +48 -0
  289. meltygui/pbr.py +1576 -0
  290. meltygui/png_unfilter.c +49 -0
  291. meltygui/resources/JetBrainsMono-Regular.ttf +0 -0
  292. meltygui/resources/THIRD_PARTY_NOTICES.md +21 -0
  293. meltygui/resources/dejavu/DejaVuSans-Bold.ttf +0 -0
  294. meltygui/resources/dejavu/DejaVuSans-ExtraLight.ttf +0 -0
  295. meltygui/resources/dejavu/DejaVuSans.ttf +0 -0
  296. meltygui/resources/dejavu/LICENSE.txt +78 -0
  297. meltygui/resources/fontawesome-LICENSE.txt +121 -0
  298. meltygui/resources/fontawesome-webfont.ttf +0 -0
  299. meltygui/resources/hdri/studio_small_09_1k.hdr +0 -0
  300. meltygui/resources/jetbrains-weights/JetBrainsMono-Bold.ttf +0 -0
  301. meltygui/resources/jetbrains-weights/JetBrainsMono-ExtraBold.ttf +0 -0
  302. meltygui/resources/jetbrains-weights/JetBrainsMono-ExtraLight.ttf +0 -0
  303. meltygui/resources/jetbrains-weights/JetBrainsMono-Light.ttf +0 -0
  304. meltygui/resources/jetbrains-weights/JetBrainsMono-Medium.ttf +0 -0
  305. meltygui/resources/jetbrains-weights/JetBrainsMono-SemiBold.ttf +0 -0
  306. meltygui/resources/jetbrains-weights/JetBrainsMono-Thin.ttf +0 -0
  307. meltygui/resources/jetbrains-weights/OFL.txt +93 -0
  308. meltygui/resources/jetbrains-weights/README.md +2 -0
  309. meltygui/state/__init__.py +0 -0
  310. meltygui/state/account_state.py +22 -0
  311. meltygui/state/animation_state.py +97 -0
  312. meltygui/state/annotation_state.py +22 -0
  313. meltygui/state/chat_state.py +36 -0
  314. meltygui/state/code_state.py +10 -0
  315. meltygui/state/core_enums.py +59 -0
  316. meltygui/state/core_markers.py +115 -0
  317. meltygui/state/core_undo.py +1075 -0
  318. meltygui/state/file_state.py +75 -0
  319. meltygui/state/graph_state.py +26 -0
  320. meltygui/state/inspection_state.py +47 -0
  321. meltygui/state/menu_state.py +10 -0
  322. meltygui/state/model_enums.py +11 -0
  323. meltygui/state/new_core_model.py +2394 -0
  324. meltygui/state/orchestration_state.py +14 -0
  325. meltygui/state/query_state.py +24 -0
  326. meltygui/state/tensor_state.py +10 -0
  327. meltygui/state/terminal_state.py +23 -0
  328. meltygui/state/trace_state.py +32 -0
  329. meltygui/state/voxel_state.py +12 -0
  330. meltygui/text_index.py +816 -0
  331. meltygui/utils/__init__.py +0 -0
  332. meltygui/utils/jump_to_code.py +344 -0
  333. meltygui/utils/pkl_inspect.py +90 -0
  334. meltygui/utils/render_utils.py +1419 -0
  335. meltygui/view/__init__.py +1 -0
  336. meltygui/view/account_view.py +524 -0
  337. meltygui/view/action_view.py +73 -0
  338. meltygui/view/chat_decoration_view.py +112 -0
  339. meltygui/view/chat_view.py +1703 -0
  340. meltygui/view/code_view.py +2677 -0
  341. meltygui/view/collection_view.py +1185 -0
  342. meltygui/view/color_view.py +824 -0
  343. meltygui/view/control_view.py +559 -0
  344. meltygui/view/decoration_view.py +439 -0
  345. meltygui/view/diagnostic_view.py +177 -0
  346. meltygui/view/dropdown_view.py +1082 -0
  347. meltygui/view/file_view.py +1599 -0
  348. meltygui/view/graph_cuda_view.py +105 -0
  349. meltygui/view/graph_view.py +882 -0
  350. meltygui/view/header_view.py +848 -0
  351. meltygui/view/input_view.py +238 -0
  352. meltygui/view/inspection_view.py +1783 -0
  353. meltygui/view/layout_view.py +596 -0
  354. meltygui/view/lut_view.py +45 -0
  355. meltygui/view/menu_view.py +212 -0
  356. meltygui/view/orchestration_view.py +658 -0
  357. meltygui/view/query_view.py +118 -0
  358. meltygui/view/search_view.py +342 -0
  359. meltygui/view/tab_view.py +258 -0
  360. meltygui/view/tensor_view.py +430 -0
  361. meltygui/view/terminal_view.py +355 -0
  362. meltygui/view/text_view.py +8031 -0
  363. meltygui/view/texture_view.py +480 -0
  364. meltygui/view/tile_view.py +38 -0
  365. meltygui/view/trace_view.py +923 -0
  366. meltygui/view/voxel_cuda_view.py +824 -0
  367. meltygui/view/voxel_view.py +1860 -0
  368. meltygui/view/window_view.py +111 -0
  369. meltygui-0.1.0.dist-info/METADATA +143 -0
  370. meltygui-0.1.0.dist-info/RECORD +372 -0
  371. meltygui-0.1.0.dist-info/WHEEL +4 -0
  372. meltygui-0.1.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,1599 @@
1
+ """Reusable file presentation; values and view state are supplied by callers."""
2
+ from pathlib import Path
3
+ from meltygui.core.runtime.paths import debug_log_path
4
+ from meltygui.core.files.file_explorer_core import _trace_browser_size
5
+ from meltygui.core.rendering.modes import Modes
6
+ from meltygui.core.core_render import render_func
7
+ from meltygui.core.rendering.render_funcs import RenderFuncs
8
+ from collections import defaultdict
9
+ from meltygui.hdr_color import pack_color
10
+ from meltygui.hdr_color import with_alpha
11
+ from meltygui.core.melty import Melty
12
+ from meltygui.state.file_state import FileExplorerState
13
+ from meltygui.state.file_state import FileSelectorState
14
+ from meltygui.state.file_state import FileTreeState
15
+ from meltygui.state.file_state import ShortcutState
16
+ from meltygui.core.runtime.toggles import Tint
17
+ from meltygui.core.runtime.toggles import Toggles
18
+ from meltygui.state.file_state import ROOT
19
+ import colorsys
20
+ import difflib
21
+ import meltygui_imgui as imgui
22
+ import os
23
+
24
+
25
+
26
+ @render_func(tint=(0.36, 0.46, 0.59), show_bg=True, selectable=False)
27
+ def draw_file_tree(input_value: dict, draw_state, root: Path = None):
28
+ """Edit a held directory tree without owning its I/O or host envelope."""
29
+ return RenderFuncs.draw_collection(
30
+ input_value, name=root.name if root is not None else "Files",
31
+ show_add_delete=True, new_item_type=str, temp=True,
32
+ width=draw_state.content_width, disable_scroll=True, show_bg=True,
33
+ child_kwargs={"show_bg": True, "bg_offset": -4,
34
+ "child_kwargs": {"show_bg": False, "is_tree": False,
35
+ "expanded": True},
36
+ "mode": Modes.FILE_TREE})
37
+
38
+
39
+ @render_func(tint=(0.18, 0.11, 0.11), selectable=False)
40
+ def draw_file_metadata(input_value: dict):
41
+ """Edit the supplied metadata mapping without looking up global stores."""
42
+ return RenderFuncs.draw_collection(input_value, name="file_meta", is_tree=True,
43
+ show_add_delete=True)
44
+
45
+
46
+
47
+ @render_func()
48
+ def draw_external_changes(draw_state=None):
49
+ from meltygui.editor.external_changes import ExternalChanges
50
+ from meltygui.editor.pending_save import _diff_lines_with_numbers
51
+
52
+ from meltygui.core.melty import Melty
53
+ from meltygui.core.melty import FileWatch
54
+ ExternalChanges._window_ds = draw_state
55
+ RenderFuncs.draw_function(ExternalChanges.dismiss_all, tint=(0, 0, 0, 1), show_bg=False, shadow=False, icon=None)
56
+
57
+ # DEBUG: what the tracker will actually see. A file only gets a baseline if
58
+ # its text sat in Melty.code_cache when the fs event fired (old_lines is the
59
+ # popped cache entry), so per watched file show whether it's cached NOW -
60
+ # "no" means an external edit to it would be skipped silently.
61
+ watched = sorted(FileWatch.path_to_draw_states.keys())
62
+ lines = [f"tracked entries: {len(ExternalChanges.originals)}"]
63
+ for p, orig in sorted(ExternalChanges.originals.items()):
64
+ lines.append(f" {p} (baseline {len(str(orig).splitlines())} lines)")
65
+ lines.append(f"watched dirs: {len(FileWatch._watched_dirs)} "
66
+ f"code_cache entries: {len(Melty.code_cache)}")
67
+ lines.append(f"watched files ({len(watched)}):")
68
+ for p in watched:
69
+ cached = "cached" if p in Melty.code_cache else "NOT in code_cache"
70
+ lines.append(f" {p} [{len(FileWatch.path_to_draw_states[p])} views, {cached}]")
71
+ RenderFuncs.draw_text("\n".join(lines), show_name=True, name="watch debug")
72
+
73
+ entries = []
74
+ for path, original in list(ExternalChanges.originals.items()):
75
+ # Once the studio itself writes the file, disk equals in-process truth
76
+ # again - the external change is resolved, drop the entry. This
77
+ # also self-heals the truncate race: a mid-write fs event can slip
78
+ # past the event-time is_self_write check (disk hash not final yet)
79
+ # and record a bogus entry, but by render time the flush has landed.
80
+ if FileWatch.is_self_write(path):
81
+ with open(debug_log_path("ext_changes_debug.log"), "a") as _f:
82
+ _f.write(f"pop self_write {path} recorded={FileWatch._self_write_hashes.get(path)} "
83
+ f"disk={FileWatch._get_hash(path)}\n")
84
+ ExternalChanges.untrack(path)
85
+ continue
86
+ current = Melty.read_code(path)
87
+ if current is not None and \
88
+ current.splitlines(keepends=True) == str(original).splitlines(keepends=True):
89
+ # Drifted back to the baseline (e.g. an outside edit was undone).
90
+ # Only heal when no absorb moved the SYNC frame past the baseline:
91
+ # after an absorb, live code is the absorbed state, so a disk
92
+ # change is a sync→disk drift the next recompile must absorb -
93
+ # untracking here would leave live and disk silently diverged.
94
+ _synced = ExternalChanges.synced.get(path)
95
+ if _synced is None or str(_synced).splitlines(keepends=True) \
96
+ == current.splitlines(keepends=True):
97
+ with open(debug_log_path("ext_changes_debug.log"), "a") as _f:
98
+ _f.write(f"pop drift_back {path}\n")
99
+ ExternalChanges.untrack(path)
100
+ continue
101
+ entries.append((path, original, current))
102
+
103
+ # Stale-blit guard. The event frame renders with the cache BYPASSED (the
104
+ # _external_change flag), so its output is never captured into the blit
105
+ # tiles - without this, the next frame replays the pre-event tile and the
106
+ # fresh diff flickers away. When the rendered diff actually changes,
107
+ # force-invalidate this window's own subtree so the tiles re-capture.
108
+ # id(current) is a content-free change signal: code_cache holds each text
109
+ # until the watcher pops it, and every re-read is a new str object. The
110
+ # absorption marker joins the sig so the "absorbed" annotation appearing
111
+ # (same texts, new baseline) still re-captures the tiles.
112
+ sig = tuple((p, id(c), ExternalChanges.absorbed.get(p)) for p, _, c in entries)
113
+ if draw_state.misc.get("_ext_sig") != sig:
114
+ draw_state.misc["_ext_sig"] = sig
115
+ Melty.cache.invalidate_up(draw_state._tile_id, force=True, max_depth=6)
116
+
117
+ for path, original, current in entries:
118
+ file_name = Path(path).name
119
+ if current is None:
120
+ if RenderFuncs.button(f" Dismiss##{path}", name=f"dismiss {path}",
121
+ tint=(0.12, 0.002037035, 0.002037035, 0.4))[0]:
122
+ ExternalChanges.untrack(path)
123
+ RenderFuncs.draw_text(f"{path}: deleted or unreadable", name=f"{file_name}##{path}")
124
+ continue
125
+ new_lines = current.splitlines(keepends=True)
126
+ old_lines = str(original).splitlines(keepends=True)
127
+
128
+ diff = difflib.unified_diff(
129
+ fromfile=path, tofile=path,
130
+ a=old_lines, b=new_lines, n=3,
131
+ )
132
+ # Whole-file diffs, so the @@ hunk numbers ARE the file's line numbers
133
+ # - base 0 (see _diff_lines_with_numbers; pending_save spans shift by
134
+ # their address.start, a whole file starts at 0).
135
+ content_lines, line_numbers = _diff_lines_with_numbers(diff, 0)
136
+ diff_str = "".join(content_lines)
137
+
138
+ if RenderFuncs.button(f" Dismiss##{path}", name=f"dismiss {path}",
139
+ tint=(0.12, 0.002037035, 0.002037035, 0.4))[0]:
140
+ # Dismiss = accept the on-disk state as the new baseline: the
141
+ # entry drops, and the next external edit re-baselines from
142
+ # whatever the cache holds then.
143
+ ExternalChanges.untrack(path)
144
+ if ExternalChanges.is_absorbed(path, current):
145
+ RenderFuncs.draw_text("absorbed into pending queue (recompiled) — "
146
+ "Dismiss to clear",
147
+ name=f"absorbed {path}", tint=(0.45, 0.75, 0.45),
148
+ show_bg=False)
149
+ RenderFuncs.draw_text(diff_str, show_name=True, name=f"{file_name}##{path}",
150
+ is_diff=True, line_numbers=line_numbers)
151
+
152
+
153
+ def draw_changed_file_header(path, tint, draw_state, view_id, width, height=23.0,
154
+ added=None, removed=None, active=False, prefix="", shadow_offset=None,
155
+ background=True):
156
+ """Shared draw-list file header for the diff column and chat tool summaries.
157
+
158
+ `shadow_offset` overrides the tab-state lift for BOTH the wash and the
159
+ button (0 = flat); None keeps the CodeEditor tab offsets.
160
+ `background=False` draws the label only — no wash, no button bg, no
161
+ shadow (the files column passes it for a file with no painted tint).
162
+ Inactive labels use the column's own compare_file_text_* knobs, brighter
163
+ than the tab bar's inactive tabs: the column has no bg to read against.
164
+ """
165
+ from meltygui.editor.file_header import _ellipsize
166
+ from meltygui.editor.source_ui import _tab_text_color
167
+ from meltygui.view.header_view import flat_button
168
+ from meltygui.core.cache.tile_cache import add_shadow
169
+
170
+ if active:
171
+ text_color = _tab_text_color(tint, Toggles.CodeEditor.tab_active_text_brightness,
172
+ Toggles.CodeEditor.tab_active_text_saturation, Toggles.CodeEditor.tab_active_text_min_brightness)
173
+ brightness, saturation = Toggles.CodeEditor.tab_active_bg_brightness, Toggles.CodeEditor.tab_active_bg_saturation
174
+ maximum, alpha = Toggles.CodeEditor.tab_active_bg_max_brightness, 0.9
175
+ shadow = Toggles.CodeEditor.tab_active_shadow_offset
176
+ else:
177
+ text_color = _tab_text_color(tint, Toggles.CodeEditor.compare_file_text_brightness,
178
+ Toggles.CodeEditor.tab_inactive_text_saturation, Toggles.CodeEditor.compare_file_text_min_brightness)
179
+ brightness, saturation = Toggles.CodeEditor.tab_inactive_bg_brightness, Toggles.CodeEditor.tab_inactive_bg_saturation
180
+ maximum, alpha = Toggles.CodeEditor.tab_inactive_bg_max_brightness, Toggles.CodeEditor.tab_inactive_bg_alpha
181
+ shadow = Toggles.CodeEditor.tab_inactive_shadow_offset
182
+ if shadow_offset is not None:
183
+ shadow = shadow_offset
184
+ if not background:
185
+ alpha, shadow = 0.0, 0.0
186
+ x, y = imgui.get_cursor_screen_pos()
187
+ dl = imgui.get_window_draw_list()
188
+ channel = Melty.get_channel()
189
+ if Melty.channels_split:
190
+ dl.channels_set_current(channel - 1)
191
+ try:
192
+ if background:
193
+ wash = Melty.style_manager.make_color_rgb(*tint[:3],
194
+ value=Toggles.CodeEditor.compare_file_bg_value, factor=0.8, saturation_scale=1.0)
195
+ if shadow_offset is None or shadow_offset:
196
+ add_shadow((x, y, width, height), offset=1.0 if shadow_offset is None else shadow_offset, corner_radius=4.0)
197
+ dl.add_rect_filled(x, y, x + width, y + height,
198
+ pack_color(*wash[:3], 1.0), rounding=4.0)
199
+ clicked = flat_button("", draw_state, view_id=view_id, width=width, height=height,
200
+ color=tuple(tint[:3]), factor=0.1, tint_value=brightness, saturation=saturation,
201
+ max_bg_brightness=maximum, alpha=alpha, shadow_offset=shadow, event="left_mouse_down")
202
+ finally:
203
+ if Melty.channels_split:
204
+ dl.channels_set_current(channel)
205
+ inset = Melty.px(2)
206
+ counts = f"+{added} -{removed}" if added is not None and removed is not None else ""
207
+ size = imgui.calc_text_size(counts)
208
+ reserve = size.x + Melty.px(8) if counts else 0
209
+ label = _ellipsize((prefix + " " if prefix else "") + Path(path).name, max(0, width - inset * 2 - reserve))
210
+ label_size = imgui.calc_text_size(label)
211
+ dl.add_text(x + inset + Melty.px(2), y + (height - label_size.y) * 0.5 - Melty.px(1),
212
+ pack_color(*text_color[:3], 1.0), label)
213
+ if counts:
214
+ left, top = x + width - inset - size.x, y + (height - size.y) * 0.5
215
+ dl.add_text(left, top, pack_color(*Tint.change_count(added=True), 1.0), f"+{added}")
216
+ dl.add_text(left + imgui.calc_text_size(f"+{added} ").x, top,
217
+ pack_color(*Tint.change_count(added=False), 1.0), f"-{removed}")
218
+ return clicked
219
+
220
+
221
+ @render_func()
222
+ def draw_pending_saves():
223
+ from meltygui.editor.pending_save import PendingSave
224
+ from meltygui.editor.pending_save import _pending_diff_memo
225
+ from meltygui.editor.pending_save import _render_diff_blocks
226
+
227
+ pass
228
+ from meltygui.core.rendering.render_dispatch import draw_any
229
+ RenderFuncs.draw_function(PendingSave.apply_all_saves, icon="", tint=(0,0,0,1), show_bg=False)
230
+ # name= keeps its draw_state distinct from apply_all_saves' (both calls
231
+ # would otherwise derive the same file-name identity); run_in_thread so
232
+ # the hotswaps run outside the render loop like every other recompile.
233
+ # result_fade_frames: the check mark + "Recompiled ..." summary hold
234
+ # briefly, then fade themselves (same fade model as code_file_io's
235
+ # recompile_status) instead of parking forever.
236
+ RenderFuncs.draw_function(PendingSave.recompile_all, name="recompile_all", icon="",
237
+ tint=(0,0,0,1), show_bg=False, run_in_thread=True,
238
+ result_fade_frames=30, temp=True)
239
+
240
+ # Result of the last external-change absorb (recompile_all's merge pass).
241
+ # Persistent, no fading if a save rewrote the file - the per-file
242
+ # MERGED / ADOPTED / CONFLICT outcome stays visible until dismissed.
243
+ if PendingSave.merge_results:
244
+ if RenderFuncs.button(" Dismiss##merge_results", name="dismiss merge_results",
245
+ tint=(0.12, 0.002037035, 0.002037035, 0.4))[0]:
246
+ PendingSave.merge_results = []
247
+ RenderFuncs.draw_text("\n".join(PendingSave.merge_results), show_name=True,
248
+ name="external merge results")
249
+
250
+ # Drop memo entries whose queue entry is gone (applied/re reverted).
251
+ for _k in list(_pending_diff_memo):
252
+ if _k not in PendingSave.pending_saves:
253
+ _pending_diff_memo.pop(_k, None)
254
+ for address, (codec, kwargs) in list(PendingSave.pending_saves.items()):
255
+ if address in PendingSave.originals:
256
+ original_data = PendingSave.originals[address]
257
+ new_data = kwargs.get("data")
258
+ old_data = original_data
259
+ # Identity-memoized diff (content-free keys - all texts are
260
+ # fresh objects on change): recompute only on real edges. A
261
+ # typing edge (same original, new pending text) splices through
262
+ # the incremental differ off the previous blocks; anything else
263
+ # runs the full matcher once. The +/-/context text and the
264
+ # and line numbers (base = address.start) render straight from
265
+ # the blocks - no unified_diff pass.
266
+ m = _pending_diff_memo.get(address)
267
+ if m is None or m[0] != id(old_data) or m[1] != id(new_data):
268
+ from meltygui.editor.diff import _diff_blocks
269
+ from meltygui.editor.diff import _incremental_diff_blocks
270
+ old_s, new_s = str(old_data), str(new_data)
271
+ blocks = None
272
+ if m is not None and m[0] == id(old_data) and m[2] is not None:
273
+ prev_new, prev_blocks = m[2]
274
+ blocks = _incremental_diff_blocks(old_s, prev_new, new_s,
275
+ prev_blocks)
276
+ if blocks is None:
277
+ blocks = _diff_blocks(old_s, new_s)
278
+ content_lines, line_numbers = _render_diff_blocks(
279
+ old_s.split("\n"), new_s.split("\n"), blocks,
280
+ address.start or 0)
281
+ m = (id(old_data), id(new_data), (new_s, blocks),
282
+ "".join(content_lines), line_numbers)
283
+ _pending_diff_memo[address] = m
284
+ diff_str, line_numbers = m[3], m[4]
285
+ if not diff_str:
286
+ continue # no-op edit (pending == original)
287
+
288
+ file_name = address.path.name
289
+ line_range = (f"({address.start}:{address.end})"
290
+ if address.start is not None else "(whole file)")
291
+ name = f"{file_name} {line_range}"
292
+ if RenderFuncs.button(f" Revert##{name}", name=f"revert {name}",
293
+ tint=(0.12, 0.002037035, 0.002037035, 0.4))[0]:
294
+ # Revert: queue the load-time text as a fresh pending edit.
295
+ # The entry stays in the queue (so sibling views still resolve
296
+ # their text through pending_data_for and pick up the revert),
297
+ # but data == original makes it a no-op for the diff view,
298
+ # recompile_all, and the eventual disk write.
299
+ PendingSave.queue_save(address, codec, **{**kwargs, "data": old_data})
300
+ # Deferred saves never touch disk, so no watcher wakes on its
301
+ # own - dispatch the file event so every view of this file
302
+ # reloads and picks the reverted text up from the pending cache.
303
+ PendingSave._wake_file_watchers(address.path)
304
+ RenderFuncs.draw_text(diff_str, show_name=True, name=name,
305
+ is_diff=True, line_numbers=line_numbers)
306
+ else:
307
+ # Baseline against DISK, never the pending cache: a plain
308
+ # codec.load answers with the queued edit itself, and stamping the
309
+ # edit as its own "original" reclassifies the entry as a no-op
310
+ # (dropped on the next disk write, invisible in this diff) and
311
+ # poisons the merge base. source_text pins the load to disk.
312
+ from meltygui.core.melty import Melty
313
+ disk_text = Melty.read_code(address.path) if address.path is not None else None
314
+ PendingSave.originals[address] = codec.load(
315
+ address=address, **{**kwargs, "source_text": disk_text})
316
+ imgui.text("No original data to compare against for address: {}".format(address))
317
+
318
+
319
+ def paint_breadcrumbs(draw_state, path, click=None, crumb_height=24.0, left_pad=6.0,
320
+ folder_bg_boost=-0.12, text_mix=0.5, width=None, file_metadata=None):
321
+ """The path strip: every segment of `path` a crumb on the window draw
322
+ list at the cursor, the last one bright (the current directory / the
323
+ file), the rest dim, a painted folder wearing its file-meta tint, the
324
+ hovered one a faint wash. Advances the cursor by the strip's height.
325
+ `click` is the caller's injected left-click position (screen space, or
326
+ None): returns the clicked segment's Path when it is not the last one,
327
+ else None. Shared by the file listing (`draw_file_listing`, the strip
328
+ over the rows) and `draw_breadcrumbs` (the strip on its own)."""
329
+ from meltygui.files.fast_file_explorer import row_tint_bg
330
+ from meltygui.files.fast_file_explorer import tinted_text
331
+ from meltygui.models.file_meta import FileMeta
332
+
333
+ # [tint=(0.55, 0.72, 0.95)]
334
+ crumb_separator = " / "
335
+ text_rgba = (0.92, 0.92, 0.92, 1.0)
336
+ text_col = pack_color(*text_rgba)
337
+ dim_col = pack_color(0.6, 0.63, 0.68, 1.0)
338
+ crumb_hover_col = pack_color(1.0, 1.0, 1.0, 0.12)
339
+
340
+ px = Melty.px
341
+ pad, crumb_h = px(left_pad), px(crumb_height)
342
+ parts = Path(path).parts
343
+ meta = file_metadata
344
+ row_bg = row_tint_bg()
345
+ draw_list = imgui.get_window_draw_list()
346
+ content_w = width if width is not None else (draw_state.content_width or draw_state.width or 240)
347
+ mouse_x, mouse_y = imgui.get_mouse_pos()
348
+ hover_ok = draw_state._bounding_hovered
349
+ x0, y0 = imgui.get_cursor_screen_pos()
350
+ crumbs = [] # (x_left, x_right, target Path)
351
+ cx = x0 + pad
352
+ crumb_y = y0 + (crumb_h - imgui.get_font_size()) * 0.5
353
+ for i, part in enumerate(parts):
354
+ label = part if part != os.sep else os.sep
355
+ label_w = imgui.calc_text_size(label).x
356
+ target = Path(*parts[:i + 1])
357
+ crumbs.append((cx - px(3), cx + label_w + px(3), target))
358
+ hovered = hover_ok and cx - px(3) <= mouse_x < cx + label_w + px(3) and y0 <= mouse_y < y0 + crumb_h
359
+ crumb_tint = FileMeta.painted_tint(meta.get(str(target))) if meta is not None else None
360
+ if crumb_tint:
361
+ draw_list.add_rect_filled(cx - px(3), y0 + px(2), cx + label_w + px(3), y0 + crumb_h - px(2),
362
+ row_bg(crumb_tint, folder_bg_boost or 0.0), rounding=px(3))
363
+ if hovered:
364
+ draw_list.add_rect_filled(cx - px(3), y0 + px(2), cx + label_w + px(3), y0 + crumb_h - px(2),
365
+ crumb_hover_col, rounding=px(3))
366
+ last = i == len(parts) - 1
367
+ crumb_col = (tinted_text(text_rgba, crumb_tint, text_mix) if crumb_tint
368
+ else text_col if last else dim_col)
369
+ draw_list.add_text(cx, crumb_y, crumb_col, label)
370
+ cx += label_w
371
+ if not last and part == os.sep:
372
+ cx += px(8) # Keep the root chip separate from the first folder.
373
+ elif not last:
374
+ draw_list.add_text(cx, crumb_y, dim_col, crumb_separator)
375
+ cx += imgui.calc_text_size(crumb_separator).x
376
+ imgui.dummy(content_w, crumb_h)
377
+ if click is not None and crumbs and y0 <= click[1] < y0 + crumb_h:
378
+ for left, right, target in crumbs[:-1]:
379
+ if left <= click[0] < right:
380
+ return target
381
+ return None
382
+
383
+
384
+ @render_func(tint=(0.32, 0.42, 0.54), selectable=False, disable_scroll=True,
385
+ show_add_delete=False, is_tree=False, show_bg=False, shadow=False)
386
+ def draw_breadcrumbs(input_value: str, draw_state, left_mouse_clicked=False,
387
+ crumb_height=24.0, left_pad=6.0, folder_bg_boost=-0.12,
388
+ text_mix=0.5, file_metadata=None, **kwargs):
389
+ """The file browser's path strip on its own (`paint_breadcrumbs`) for a
390
+ host that shows one path — the code editor draws it along the top of
391
+ the selected file's column (``show_breadcrumbs=True``), the mirror of
392
+ the tab bar along its bottom. `input_value` is the path (a directory or
393
+ a file; a file's name is the last, bright crumb). A click on any other
394
+ crumb returns ``(True, directory)`` once, the host deciding what a
395
+ directory pick means; otherwise ``(False, input_value)``."""
396
+ if not input_value:
397
+ return False, input_value
398
+ click = ((left_mouse_clicked.x, left_mouse_clicked.y)
399
+ if (left_mouse_clicked and hasattr(left_mouse_clicked, "x")) else None)
400
+ target = paint_breadcrumbs(draw_state, input_value, click=click,
401
+ crumb_height=crumb_height, left_pad=left_pad,
402
+ folder_bg_boost=folder_bg_boost, text_mix=text_mix,
403
+ file_metadata=file_metadata)
404
+ if target is not None:
405
+ from meltygui.core.windowing.glfw_utils import request_render
406
+ request_render()
407
+ return True, str(target)
408
+ return False, input_value
409
+
410
+
411
+ @render_func(tint=(0.32, 0.42, 0.54), selectable=False, disable_scroll=False,
412
+ show_add_delete=False, is_tree=False, show_bg=False, shadow=False)
413
+ def draw_file_listing(input_value: str, draw_state, explorer_state: FileExplorerState, file_metadata=None,
414
+ left_mouse_down=False, left_mouse_double_clicked=False,
415
+ right_mouse_down=False,
416
+ ctrl_up_key_pressed=False, up_key_pressed=False, down_key_pressed=False,
417
+ enter_key_pressed=False, escape_key_pressed=False,
418
+ row_height=20.0, left_pad=6.0, glyph_width=18.0, crumb_height=24.0,
419
+ show_tint_chips=True, chip_size=17.0, default_tint=(0.32, 0.42, 0.54, 1.0),
420
+ select_boost=0.22, plain_select_boost=0.06, select_shadow=2.0,
421
+ select_rounding=3.0, chip_mix=0.55, hover_boost=0.06, hover_alpha=0.05, text_mix=0.5, icon_mix=0.9,
422
+ folder_bg_boost=-0.12, folder_bg_rounding=0.0, drag_rows=True, menu_target=None,
423
+ type_to_search=True, search_tint=(1.0, 0.82, 0.3), search_dim=0.45,
424
+ search_flash_frames=36, show_crumbs=True, show_hidden=None,
425
+ **kwargs):
426
+ """The path strip + rows of one directory (see the module docstring).
427
+ `show_crumbs=False` leaves the strip out (a host drawing the crumbs in
428
+ its own toolbar, where they stay put while the rows scroll).
429
+ Returns ``(True, path)`` on navigation / a file double-click, else
430
+ ``(False, input_value)``. `show_tint_chips` puts the tint chip / brush
431
+ before each row's icon; `default_tint` is what the brush stamps. The
432
+ selected row is its own tint brightened by `select_boost` (an unpainted
433
+ row: `default_tint` by the smaller `plain_select_boost`, so it stays
434
+ close to the background), lifted off the list by an add_shadow of
435
+ `select_shadow` depth (0 disables it). `chip_mix` pulls the tint chip's
436
+ colour toward its row background (0 = the raw tint). Hover is the same
437
+ tint brightened by `hover_boost` on the selected row; any other hovered
438
+ row gets only a faint white wash of `hover_alpha`. A painted
439
+ row wears its tint on its text (mixed `text_mix` toward the tint) and
440
+ its icon (`icon_mix`, stronger), not as a row background. `type_to_search`
441
+ is the keyboard search of the module docstring: `search_tint` colours
442
+ the matched letters and the pill, the non-matching rows' text fades to
443
+ `search_dim` of its alpha while there are matches, and the flash on the
444
+ row a keystroke lands on fades over `search_flash_frames` frames."""
445
+ from meltygui.model.file_model import _dir_mtime_ns
446
+ from meltygui.files.fast_file_explorer import _scroll_row_into_view
447
+ from meltygui.model.file_metadata_model import apply_row_drop
448
+ from meltygui.files.fast_file_explorer import chip_swatch
449
+ from meltygui.files.fast_file_explorer import claim_keyboard
450
+ from meltygui.model.file_model import list_directory
451
+ from meltygui.model.file_metadata_model import ordered_rows
452
+ from meltygui.files.fast_file_explorer import row_icon
453
+ from meltygui.files.fast_file_explorer import row_tint_bg
454
+ from meltygui.files.fast_file_explorer import search_hits
455
+ from meltygui.files.fast_file_explorer import search_keys
456
+ from meltygui.files.fast_file_explorer import search_typed
457
+ from meltygui.model.file_metadata_model import set_row_order
458
+ from meltygui.files.fast_file_explorer import tint_control
459
+ from meltygui.model.file_metadata_model import set_row_tint
460
+ from meltygui.files.fast_file_explorer import tinted_text
461
+ from meltygui.core.files.file_explorer_core import watch_directory
462
+ from meltygui.models.file_meta import FileMeta
463
+ from meltygui.core.windowing.glfw_utils import request_render
464
+ from meltygui.core.cache.tile_cache import add_shadow
465
+ from meltygui.core.cache.tile_cache import clear_glows
466
+ from meltygui.core.input.drag_drop_core import DragDrop
467
+
468
+ # [tint=(0.55, 0.72, 0.95)]
469
+ folder_icon = f""
470
+ # [tint=(0.55, 0.72, 0.95)]
471
+ file_icon = f""
472
+ text_rgba = (0.92, 0.92, 0.92, 1.0)
473
+ text_col = pack_color(*text_rgba)
474
+ dim_col = pack_color(0.6, 0.63, 0.68, 1.0)
475
+ folder_rgba = (0.78, 0.84, 0.92, 1.0)
476
+ folder_col = pack_color(*folder_rgba)
477
+ search_wash = pack_color(search_tint[0], search_tint[1], search_tint[2], 0.30)
478
+ search_col = pack_color(search_tint[0], search_tint[1], search_tint[2], 1.0)
479
+ no_match_col = pack_color(0.95, 0.55, 0.5, 1.0)
480
+ hover_wash = pack_color(1.0, 1.0, 1.0, hover_alpha)
481
+
482
+ state = explorer_state
483
+ # The selected row's add_shadow is RETAINED under this draw_state until
484
+ # the caller opens its group again: a selection that vanishes (the file
485
+ # trashed, Esc, a new directory) would otherwise keep its shadow.
486
+ clear_glows(draw_state)
487
+ px = Melty.px
488
+ row_h, pad, glyph_w, crumb_h = px(row_height), px(left_pad), px(glyph_width), px(crumb_height)
489
+ chip = px(chip_size) if show_tint_chips else 0.0
490
+ # The tint control leads the row; icon & name shift right past it.
491
+ chip_x = 0.0
492
+ text_x = pad + (chip + px(6) if show_tint_chips else 0.0)
493
+ directory = Path(input_value if input_value else Path.home()).expanduser()
494
+ dir_key = str(directory)
495
+ draw_list = imgui.get_window_draw_list()
496
+ content_w = draw_state.content_width or (draw_state.width or 240)
497
+ mouse_x, mouse_y = imgui.get_mouse_pos()
498
+ hover_ok = draw_state._bounding_hovered
499
+ click = ((left_mouse_down.x, left_mouse_down.y)
500
+ if (left_mouse_down and hasattr(left_mouse_down, "x")) else None)
501
+ double_click = ((left_mouse_double_clicked.x, left_mouse_double_clicked.y)
502
+ if (left_mouse_double_clicked and hasattr(left_mouse_double_clicked, "x")) else None)
503
+ right_press = ((right_mouse_down.x, right_mouse_down.y)
504
+ if (right_mouse_down and hasattr(right_mouse_down, "x")) else None)
505
+ meta = file_metadata
506
+ row_bg = row_tint_bg()
507
+
508
+ def navigate(target):
509
+ """Leave `dir_key` for `target`: remember where this listing was
510
+ scrolled so a return lands there, and hand the path to the caller."""
511
+ state.scroll_by_dir[dir_key] = draw_state.scroll_offset[1]
512
+ request_render()
513
+ return True, str(target)
514
+
515
+ # ── a new directory: restore the scroll, drop the selection, move the watch ──
516
+ if state._last_dir != dir_key:
517
+ state._last_dir = dir_key
518
+ state.selected = None
519
+ state._search, state._search_for = "", None
520
+ draw_state.scroll_offset = (0.0, state.scroll_by_dir.get(dir_key, 0.0))
521
+ draw_state.invalidate()
522
+ watch_directory(draw_state, state, dir_key)
523
+
524
+ # ── listing, memoized by the directory's mtime (renames / new files bump it) ──
525
+ mtime = _dir_mtime_ns(directory)
526
+ listing = state._listing
527
+ if show_hidden is not None:
528
+ state.show_hidden = show_hidden
529
+ if listing is None or listing[:3] != (dir_key, mtime, state.show_hidden):
530
+ listing = state._listing = (dir_key, mtime, state.show_hidden,
531
+ list_directory(directory, state.show_hidden))
532
+ rows = ordered_rows(listing[3], meta)
533
+
534
+ # ── the path strip: every segment a crumb; click = jump there ──
535
+ if show_crumbs:
536
+ target = paint_breadcrumbs(draw_state, directory, click=click,
537
+ crumb_height=crumb_height, left_pad=left_pad,
538
+ file_metadata=file_metadata,
539
+ folder_bg_boost=folder_bg_boost, text_mix=text_mix)
540
+ if target is not None:
541
+ return navigate(target)
542
+
543
+ # ── content height: rows + the top inset (the fast_dock rule) ──
544
+ rows_x, rows_y = imgui.get_cursor_screen_pos()
545
+ # ── a painted directory: a tint wash below the breadcrumb strip ──
546
+ dir_tint = FileMeta.painted_tint(meta.get(dir_key)) if meta is not None else None
547
+ if dir_tint and folder_bg_boost is not None:
548
+ wash = getattr(draw_state, "abs_clip_rect", None)
549
+ if wash is None:
550
+ wash = (draw_state.abs_left, draw_state.abs_top,
551
+ draw_state.abs_left + (draw_state.width or 0),
552
+ draw_state.abs_top + (draw_state.height or 0))
553
+ wash_top = max(wash[1], rows_y)
554
+ if wash_top < wash[3]:
555
+ draw_list.add_rect_filled(wash[0], wash_top, wash[2], wash[3],
556
+ row_bg(dir_tint, folder_bg_boost), rounding=px(folder_bg_rounding))
557
+
558
+ top_inset = (rows_y + draw_state.scroll_offset[1]) - draw_state.abs_top
559
+ imgui.dummy(content_w, max(1.0, len(rows) * row_h + max(0.0, top_inset)))
560
+
561
+ # ── input on rows ──
562
+ # The tint control's widget (chip / brush) at the row's left edge is
563
+ # the chip's own click, never the row's - a double-click there must
564
+ # not navigate.
565
+ chip_x = rows_x + pad
566
+ clip = getattr(draw_state, "abs_clip_rect", None)
567
+
568
+ drag_left = chip_x + chip + px(2) if show_tint_chips else rows_x
569
+
570
+ def row_at(point, chips=False):
571
+ if point is None or not (rows_x <= point[0] <= rows_x + content_w):
572
+ return None
573
+ if not chips and show_tint_chips and point[0] < drag_left:
574
+ return None
575
+ index = int((point[1] - rows_y) // row_h)
576
+ return index if 0 <= index < len(rows) else None
577
+
578
+ selected_index = None
579
+ if state.selected is not None:
580
+ for i, (path, _is_dir) in enumerate(rows):
581
+ if str(path) == state.selected:
582
+ selected_index = i
583
+ break
584
+
585
+ # ── the keyboard: the type-to-search queue when this listing owns it,
586
+ # else the hover-routed key params (the find box has the keyboard, say) ──
587
+ owns_keys = type_to_search and claim_keyboard(draw_state)
588
+ query = state._search if type_to_search else ""
589
+ step = (1 if down_key_pressed else 0) - (1 if up_key_pressed else 0)
590
+ if owns_keys:
591
+ keys = search_keys()
592
+ query, step, enter_key_pressed, ctrl_up_key_pressed, escape_key_pressed = \
593
+ search_typed(query, keys)
594
+ if keys:
595
+ draw_state.invalidate()
596
+ request_render()
597
+ if query != state._search:
598
+ state._search = query
599
+ rows_top = rows_y - draw_state.abs_top + draw_state.scroll_offset[1]
600
+ view_rect = clip if clip is not None else (
601
+ draw_state.abs_left, draw_state.abs_top,
602
+ draw_state.abs_left + (draw_state.width or 0), draw_state.abs_top + (draw_state.height or 0))
603
+
604
+ def flash_row(index):
605
+ """Melty.emphasize on row `index`: a fire-and-forget flash whose rect
606
+ follows the scroll, scissored to the listing (keyed by the row's
607
+ path, so a new target starts a fresh fade)."""
608
+ top = rows_top + index * row_h
609
+
610
+ def rect(ds=draw_state, top=top, h=row_h, x0=rows_x, w=content_w):
611
+ y = ds.abs_top + top - ds.scroll_offset[1]
612
+ return (x0, y, x0 + w, y + h)
613
+
614
+ def clip_rect(ds=draw_state, fallback=view_rect):
615
+ return getattr(ds, "abs_clip_rect", None) or fallback
616
+
617
+ Melty.emphasize(f"explorer search {draw_state._tile_id} {rows[index][0]}", rect,
618
+ clip=clip_rect, rounding=px(select_rounding),
619
+ fade_frames=search_flash_frames)
620
+
621
+ def jump_to_row(index):
622
+ """Select row `index` and bring it into view; the flash only when the
623
+ landing row CHANGES (a keystroke that keeps the same best match, or
624
+ the same step target, is quiet)."""
625
+ moved = index != selected_index
626
+ state.selected = str(rows[index][0])
627
+ _scroll_row_into_view(draw_state, index, row_h, rows_top, centre=True,
628
+ content_h=len(rows) * row_h + max(0.0, top_inset))
629
+ if moved:
630
+ flash_row(index)
631
+ request_render()
632
+ return index
633
+
634
+ hit = row_at(double_click)
635
+ if hit is not None:
636
+ return navigate(rows[hit][0])
637
+ hit = row_at(click)
638
+ if hit is not None:
639
+ state.selected = str(rows[hit][0])
640
+ selected_index = hit
641
+ request_render()
642
+ # A right-press selects the row under it (the chip column included): the
643
+ # wrapper opens the context menu on the release, on that row.
644
+ hit = row_at(right_press, chips=True)
645
+ if hit is not None:
646
+ state.selected = str(rows[hit][0])
647
+ selected_index = hit
648
+ draw_state.invalidate()
649
+ request_render()
650
+ elif right_press is not None and state.selected is not None:
651
+ # Empty space: the menu acts on the dir, not a stale selection.
652
+ state.selected = None
653
+ selected_index = None
654
+ draw_state.invalidate()
655
+ request_render()
656
+ if menu_target is not None:
657
+ menu_target["path"] = state.selected if selected_index is not None else dir_key
658
+ if ctrl_up_key_pressed and directory.parent != directory:
659
+ return navigate(directory.parent)
660
+ if enter_key_pressed and selected_index is not None:
661
+ return navigate(rows[selected_index][0])
662
+ if escape_key_pressed and state.selected is not None:
663
+ state.selected = None
664
+ draw_state.invalidate()
665
+ request_render()
666
+
667
+ # ── the search: rank the rows, land on the best, step through the rest ──
668
+ # A query change (or a selection that left no matches: a click in the
669
+ # directory changed under the watch) lands on the best match; Up / Down
670
+ # / Tab walk the matches in listing order, wrapping. Without a query the
671
+ # steps walk the whole listing as before. No match: the selection stays
672
+ # where it was and the listing says so.
673
+ hits, best = search_hits(rows, query) if query else ([], None)
674
+ hit_rows = {index: spans for index, _rank, spans in hits}
675
+ if query and hits:
676
+ positions = [index for index, _rank, _spans in hits]
677
+ if state._search_for != query or selected_index not in hit_rows:
678
+ selected_index = jump_to_row(positions[best])
679
+ elif step:
680
+ at = positions.index(selected_index)
681
+ selected_index = jump_to_row(positions[(at + step) % len(positions)])
682
+ elif step and rows and not query:
683
+ selected_index = (0 if selected_index is None and step > 0
684
+ else len(rows) - 1 if selected_index is None
685
+ else max(0, min(len(rows) - 1, selected_index + step)))
686
+ state.selected = str(rows[selected_index][0])
687
+ _scroll_row_into_view(draw_state, selected_index, row_h, rows_top)
688
+ request_render()
689
+ state._search_for = query if query else None
690
+ if query and not hits and step:
691
+ request_render()
692
+ dimmed = bool(query and hits)
693
+
694
+ # ── rows: viewport-culled, straight to the draw list ──
695
+ text_y_pad = (row_h - imgui.get_font_size()) * 0.5
696
+ ghost_alpha = 0.9
697
+ drag_keys = [] # the visible rows' paths, in on_drag call order
698
+ first_visible = None # index into `rows` of drag_keys[0]
699
+ for i, (path, is_dir) in enumerate(rows):
700
+ ry0 = rows_y + i * row_h
701
+ ry1 = ry0 + row_h
702
+ if clip is not None and (ry1 < clip[1] or ry0 > clip[3]):
703
+ continue
704
+ key = str(path)
705
+ entry = meta.get(key) if meta is not None else None
706
+ tint = FileMeta.painted_tint(entry)
707
+ icon = row_icon(path, is_dir, entry, folder_icon, file_icon)
708
+ spans = hit_rows.get(i) if dimmed else None
709
+ name_rgba, glyph_rgba = (folder_rgba if is_dir else text_rgba), text_rgba
710
+ if dimmed and spans is None:
711
+ name_rgba = name_rgba[:3] + (name_rgba[3] * search_dim,)
712
+ glyph_rgba = glyph_rgba[:3] + (glyph_rgba[3] * search_dim,)
713
+ if tint:
714
+ name_col = tinted_text(name_rgba, tint, text_mix)
715
+ icon_col = tinted_text(glyph_rgba, tint, icon_mix)
716
+ elif dimmed and spans is None:
717
+ name_col = pack_color(*name_rgba)
718
+ icon_col = pack_color(*glyph_rgba)
719
+ else:
720
+ name_col = icon_col = folder_col if is_dir else text_col
721
+ if drag_rows:
722
+ # The tab bar's immediate-mode DragDrop: the row (past the chip
723
+ # column) is its own drag handle. While THIS row is the drag,
724
+ # on_drag has parked the cursor at the ghost: paint the row
725
+ # there on the overlay, but leave the inline row alone
726
+ # (DragDrop treats it as the home / drop target).
727
+ if first_visible is None:
728
+ first_visible = i
729
+ drag_keys.append(path)
730
+ drag = DragDrop.on_drag((drag_left, ry0, rows_x + content_w, ry1), key=key,
731
+ draw_state=draw_state)
732
+ if drag:
733
+ ghost = drag.draw_list
734
+ ghost.add_rect_filled(drag.x, drag.y, drag.x + drag.w, drag.y + drag.h,
735
+ pack_color(*row_bg.rgb(tint or default_tint, select_boost),
736
+ ghost_alpha),
737
+ rounding=px(select_rounding))
738
+ ghost.add_text(drag.x + px(4), drag.y + text_y_pad, icon_col, icon)
739
+ ghost.add_text(drag.x + px(4) + glyph_w, drag.y + text_y_pad, name_col, path.name)
740
+ DragDrop.end_drag()
741
+ continue
742
+ row_hovered = hover_ok and rows_x <= mouse_x <= rows_x + content_w and ry0 <= mouse_y < ry1
743
+ # Selection / hover are brighter steps of the row's own tint
744
+ # (the default tint when unpainted); they stack.
745
+ boost = (select_boost if tint else plain_select_boost) if i == selected_index else 0.0
746
+ if i == selected_index and select_shadow:
747
+ add_shadow((rows_x, ry0, content_w, row_h), offset=select_shadow,
748
+ corner_radius=px(select_rounding), clip=clip, draw_state=draw_state)
749
+ if boost:
750
+ draw_list.add_rect_filled(rows_x, ry0, rows_x + content_w, ry1,
751
+ row_bg(tint or default_tint,
752
+ boost + (hover_boost if row_hovered else 0.0)),
753
+ rounding=px(select_rounding))
754
+ elif row_hovered:
755
+ draw_list.add_rect_filled(rows_x, ry0, rows_x + content_w, ry1, hover_wash,
756
+ rounding=px(select_rounding))
757
+ draw_list.add_text(rows_x + text_x, ry0 + text_y_pad, icon_col, icon)
758
+ name_x = rows_x + text_x + glyph_w
759
+ if spans:
760
+ # The matched letters: a wash of the search tint over them.
761
+ name = path.name
762
+ for start, end in spans:
763
+ sx0 = name_x + imgui.calc_text_size(name[:start]).x
764
+ sx1 = sx0 + imgui.calc_text_size(name[start:end]).x
765
+ draw_list.add_rect_filled(sx0 - px(1), ry0 + px(2), sx1 + px(1), ry1 - px(2),
766
+ search_wash, rounding=px(2))
767
+ draw_list.add_text(name_x, ry0 + text_y_pad, name_col, path.name)
768
+ if show_tint_chips:
769
+ swatch = None
770
+ if tint:
771
+ swatch = chip_swatch(tint, row_bg.rgb(tint, boost), chip_mix)
772
+ tint_control(draw_state, key, tint, chip_x, ry0 + (row_h - chip) * 0.5, chip,
773
+ ry0 + text_y_pad, row_hovered, default_tint, swatch=swatch,
774
+ setter=lambda value, path=key: set_row_tint(meta, path, value),
775
+ show_brush=i == selected_index)
776
+
777
+ # ── the search pill: the query and "n of m", bottom right of the view ──
778
+ if query:
779
+ icon_search = "\uf002"
780
+ count = (f"{positions.index(selected_index) + 1} of {len(hits)}"
781
+ if hits and selected_index in hit_rows else "no match")
782
+ pill_h = px(26)
783
+ pad_x, gap = px(11), px(12)
784
+ query_w = imgui.calc_text_size(query).x
785
+ icon_w = imgui.calc_text_size(icon_search).x
786
+ count_w = imgui.calc_text_size(count).x
787
+ pill_w = pad_x + icon_w + px(8) + query_w + gap + count_w + pad_x
788
+ px1 = view_rect[2] - px(14)
789
+ py1 = view_rect[3] - px(12)
790
+ px0 = max(view_rect[0] + px(6), px1 - pill_w)
791
+ py0 = py1 - pill_h
792
+ draw_list.add_rect_filled(px0 + px(1), py0 + px(2), px1 + px(1), py1 + px(2),
793
+ pack_color(0.0, 0.0, 0.0, 0.35), rounding=pill_h * 0.5)
794
+ draw_list.add_rect_filled(px0, py0, px1, py1,
795
+ pack_color(*row_bg.rgb(default_tint, -0.02), 0.97),
796
+ rounding=pill_h * 0.5)
797
+ draw_list.add_rect(px0, py0, px1, py1, pack_color(1.0, 1.0, 1.0, 0.14),
798
+ rounding=pill_h * 0.5)
799
+ pill_ty = py0 + (pill_h - imgui.get_font_size()) * 0.5
800
+ draw_list.add_text(px0 + pad_x, pill_ty, search_col, icon_search)
801
+ draw_list.add_text(px0 + pad_x + icon_w + px(8), pill_ty, text_col, query)
802
+ draw_list.add_text(px1 - pad_x - count_w, pill_ty,
803
+ dim_col if hits else no_match_col, count)
804
+
805
+ # ── close the drag body: paint the between-row slots, apply a drop ──
806
+ # A reorder lands in on_drag call order = the visible rows, a contiguous
807
+ # slice of `rows`; splice the new slice in and stamp the whole
808
+ # directory's order (folders and files together - the stamps are the order
809
+ # from now on). Cross-collection kinds ("insert" / "remove") are ignored:
810
+ # rows only reorder here.
811
+ if drag_rows:
812
+ drop = DragDrop.on_drop(draw_state=draw_state)
813
+ order = apply_row_drop(rows, drag_keys, first_visible, drop)
814
+ if order is not None:
815
+ set_row_order(meta, order)
816
+ draw_state.invalidate()
817
+ request_render()
818
+
819
+ return False, input_value
820
+
821
+
822
+ @render_func(tint=(0.32, 0.42, 0.54), selectable=False, disable_scroll=True, use_cache=True,
823
+ show_add_delete=False, is_tree=False, show_bg=False, shadow=False)
824
+ def draw_shortcuts(input_value: str, draw_state, file_metadata=None, left_mouse_clicked=False,
825
+ shortcut_state: ShortcutState = None, row_height=22.0,
826
+ show_tint_chips=True, chip_size=17.0, default_tint=(0.32, 0.42, 0.54, 1.0),
827
+ show_projects=True, **kwargs):
828
+ """The shortcuts column on its own: home, the XDG user directories and
829
+ the root as draw-list rows, then a **Projects** section — every folder
830
+ marked with meltygui.mark_project (the shared file-meta store's flag) —
831
+ and a click returns ``(True, path)`` once. `input_value` is the
832
+ directory the host shows (or None): the deepest shortcut / project
833
+ holding it is the CURRENT row (brighter, lifted by a shadow, its brush
834
+ showing). Every row leads with its folder's tint control (the same
835
+ file-meta tint the listings wear). Shortcuts drag to reorder, the order
836
+ persisting in `shortcut_state`; projects keep the store's path order.
837
+ The explorer draws this in its first cell; the code editor draws it as
838
+ a leading column (`show_shortcuts=True`), a pick selecting the project
839
+ in its injected `EditorProjectState`."""
840
+ from meltygui.core.runtime.extensions import source_folders as project_roots
841
+ from meltygui.files.fast_file_explorer import chip_swatch
842
+ from meltygui.files.fast_file_explorer import row_tint_bg
843
+ from meltygui.files.fast_file_explorer import shortcut_directories
844
+ from meltygui.files.fast_file_explorer import tint_control
845
+ from meltygui.model.file_metadata_model import set_row_tint
846
+ from meltygui.files.fast_file_explorer import tinted_text
847
+ from meltygui.models.file_meta import FileMeta
848
+ from meltygui.core.windowing.glfw_utils import request_render
849
+ from meltygui.core.cache.tile_cache import add_shadow
850
+ from meltygui.core.cache.tile_cache import clear_glows
851
+ from meltygui.core.input.drag_drop_core import DragDrop
852
+
853
+ # [tint=(0.55, 0.72, 0.95)]
854
+ folder_icon = f""
855
+ # [tint=(0.55, 0.72, 0.95)]
856
+ computer_icon = f""
857
+ # [tint=(0.55, 0.72, 0.95)]
858
+ left_pad = 8.0
859
+ # [tint=(0.55, 0.72, 0.95)]
860
+ glyph_width = 20.0
861
+ text_rgba = (0.85, 0.88, 0.92, 1.0)
862
+ text_col = pack_color(*text_rgba)
863
+ # [tint=(0.55, 0.72, 0.95)]
864
+ hover_boost = 0.06
865
+ # [tint=(0.55, 0.72, 0.95)]
866
+ hover_alpha = 0.05
867
+ # [tint=(0.55, 0.72, 0.95)]
868
+ text_mix = 0.5
869
+ # [tint=(0.55, 0.72, 0.95)]
870
+ icon_mix = 0.9
871
+ hover_wash = pack_color(1.0, 1.0, 1.0, hover_alpha)
872
+
873
+ px = Melty.px
874
+ clear_glows(draw_state) # the current row's retained shadow
875
+ directory = Path(input_value).expanduser() if input_value else None
876
+ draw_list = imgui.get_window_draw_list()
877
+ mouse_x, mouse_y = imgui.get_mouse_pos()
878
+ hover_ok = draw_state._bounding_hovered
879
+ click = ((left_mouse_clicked.x, left_mouse_clicked.y)
880
+ if (left_mouse_clicked and hasattr(left_mouse_clicked, "x")) else None)
881
+ chip = px(chip_size) if show_tint_chips else 0.0
882
+ text_x = px(left_pad) + (chip + px(6) if show_tint_chips else 0.0)
883
+ meta = file_metadata
884
+ row_bg = row_tint_bg()
885
+ x, y = imgui.get_cursor_screen_pos()
886
+ width = float(draw_state.content_width or draw_state.width or px(190))
887
+ row_h = px(row_height)
888
+ shortcuts = shortcut_directories()
889
+ ranks = {key: i for i, key in enumerate(shortcut_state.order)}
890
+ shortcuts.sort(key=lambda item: ranks.get(str(item[1]), len(ranks)))
891
+ # The second section: every folder marked as a project - an alphabetical
892
+ # list of the projects, the same rows in path order, no drag-reorder
893
+ # (the set is the store's, not this column's).
894
+ projects = ([(root.name or str(root), root) for root in project_roots()]
895
+ if show_projects else [])
896
+ # The shortcut / project that holds the current directory: the deepest one.
897
+ current = None
898
+ if directory is not None:
899
+ for label, path in shortcuts + projects:
900
+ if path == directory or path in directory.parents:
901
+ if current is None or len(path.parts) > len(current.parts):
902
+ current = path
903
+ text_y_pad = (row_h - imgui.get_font_size()) * 0.5
904
+ chip_x = x + px(left_pad)
905
+ # The tint control's column: the chip's own click, never the row's.
906
+ click_left = chip_x + chip + px(2) if show_tint_chips else x
907
+ section_gap = row_h * 0.5
908
+
909
+ def draw_rows(rows, y, draggable):
910
+ """One section of rows from `y` down; returns the picked path."""
911
+ picked = None
912
+ for i, (label, path) in enumerate(rows):
913
+ ry0 = y + i * row_h
914
+ ry1 = ry0 + row_h
915
+ key = str(path)
916
+ tint = FileMeta.painted_tint(meta.get(key)) if meta is not None else None
917
+ icon = computer_icon if path == Path(os.sep) else folder_icon
918
+ label_col = tinted_text(text_rgba, tint, text_mix) if tint else text_col
919
+ icon_col = tinted_text(text_rgba, tint, icon_mix) if tint else text_col
920
+ drag = DragDrop.on_drag((click_left, ry0, x + width, ry1), key=key,
921
+ draw_state=draw_state) if draggable else None
922
+ if drag:
923
+ ghost = drag.draw_list
924
+ ghost.add_rect_filled(drag.x, drag.y, drag.x + drag.w, drag.y + drag.h,
925
+ row_bg(tint or default_tint, 0.22), rounding=px(4))
926
+ ghost.add_text(drag.x + px(4), drag.y + text_y_pad, icon_col, icon)
927
+ ghost.add_text(drag.x + px(4) + px(glyph_width), drag.y + text_y_pad,
928
+ label_col, label)
929
+ DragDrop.end_drag()
930
+ continue
931
+ row_hovered = hover_ok and x <= mouse_x < x + width and ry0 <= mouse_y < ry1
932
+ # The current row: its tint (default when unpainted), brighter,
933
+ # lifted above the column by a soft shadow; hover is a smaller
934
+ # brightening of the same tint.
935
+ boost = (0.22 if tint else 0.06) if path == current else 0.0
936
+ if path == current:
937
+ add_shadow((x, ry0, width, row_h), offset=2.0, corner_radius=px(4),
938
+ clip=getattr(draw_state, "abs_clip_rect", None), draw_state=draw_state)
939
+ if boost:
940
+ draw_list.add_rect_filled(x, ry0, x + width, ry1,
941
+ row_bg(tint or default_tint,
942
+ boost + (hover_boost if row_hovered else 0.0)),
943
+ rounding=px(4))
944
+ elif row_hovered:
945
+ draw_list.add_rect_filled(x, ry0, x + width, ry1, hover_wash, rounding=px(4))
946
+ draw_list.add_text(x + text_x, ry0 + text_y_pad, icon_col, icon)
947
+ draw_list.add_text(x + text_x + px(glyph_width), ry0 + text_y_pad, label_col, label)
948
+ if (click is not None and click_left <= click[0] < x + width and ry0 <= click[1] < ry1
949
+ and path != directory):
950
+ picked = path
951
+ if show_tint_chips:
952
+ swatch = None
953
+ if tint:
954
+ swatch = chip_swatch(tint, row_bg.rgb(tint, boost))
955
+ tint_control(draw_state, key, tint, chip_x, ry0 + (row_h - chip) * 0.5, chip,
956
+ ry0 + text_y_pad, row_hovered, default_tint, swatch=swatch,
957
+ setter=lambda value, path=key: set_row_tint(meta, path, value),
958
+ show_brush=path == current)
959
+ return picked
960
+
961
+ picked = draw_rows(shortcuts, y, draggable=True)
962
+ total_h = len(shortcuts) * row_h
963
+ if projects:
964
+ # A dim "Projects" heading half a row below the shortcuts.
965
+ head_y = y + total_h + section_gap
966
+ draw_list.add_text(x + text_x, head_y + text_y_pad,
967
+ imgui.get_color_u32_rgba(*text_rgba[:3], text_rgba[3] * 0.55),
968
+ "Projects")
969
+ picked = draw_rows(projects, head_y + row_h, draggable=False) or picked
970
+ total_h += section_gap + row_h + len(projects) * row_h
971
+ drop = DragDrop.on_drop(draw_state=draw_state)
972
+ if drop is not None and drop.kind == "reorder" and drop.apply(shortcuts):
973
+ shortcut_state.order = [str(path) for _label, path in shortcuts]
974
+ picked = None
975
+ imgui.dummy(width, total_h)
976
+ if picked is not None:
977
+ request_render()
978
+ return True, str(picked)
979
+ return False, input_value
980
+
981
+
982
+ @render_func(tint=(0.32, 0.42, 0.54), selectable=False, disable_scroll=True,
983
+ show_add_delete=False, is_tree=False, show_bg=False, shadow=False)
984
+ def draw_fast_file_explorer(input_value: str, draw_state, file_metadata=None, column_edges=None,
985
+ left_mouse_clicked=False, ctrl_up_key_pressed=False,
986
+ shortcuts_width=190.0, shortcut_row_height=22.0, column_gap=6.0,
987
+ show_tint_chips=True, chip_size=17.0, default_tint=(0.32, 0.42, 0.54, 1.0),
988
+ context_menu=None, drag_rows=True, folder_bg_boost=-0.12,
989
+ folder_bg_rounding=0.0, type_to_search=True, show_crumbs=True,
990
+ layout_out=None, show_hidden=None, **kwargs):
991
+ """A ColumnLayout with two cells: `draw_shortcuts` (a click navigates)
992
+ and `draw_file_listing`, sharing one draggable edge
993
+ (`column_edges`, persisted by auto-state). Returns what the listing
994
+ returns; Ctrl+Up works from anywhere over the explorer. Shortcut rows
995
+ wear their directory's tint like the listing's, with the same leading
996
+ tint control (`show_tint_chips`, `chip_size`, `default_tint`).
997
+ `context_menu` = {label: callable(path)} is the listing's right-click
998
+ menu; each callable gets the path of the right-clicked row, or of the
999
+ directory (see the module docstring). `drag_rows` / `folder_bg_boost`
1000
+ go to the listing, as does `type_to_search` (the keyboard search of the
1001
+ module docstring; False leaves the keyboard alone), and `show_crumbs`
1002
+ (False drops the listing's path strip for a host that draws its own).
1003
+ `layout_out`, a dict, receives ``listing_left``: the absolute x where
1004
+ the listing column's content starts, so a host toolbar can line up with
1005
+ it (the frame's value lands after this call; a host drawing above the
1006
+ explorer reads last frame's). Shortcuts drag to reorder, with their own
1007
+ persisted order."""
1008
+ _trace_browser_size("file-explorer-size", draw_state,
1009
+ content_width=draw_state.content_width,
1010
+ size_change=getattr(draw_state, "size_change", None))
1011
+ from meltygui.files.fast_file_explorer import row_tint_bg
1012
+ from meltygui.core.windowing.glfw_utils import request_render
1013
+ from meltygui.core.cache.tile_cache import clear_glows
1014
+ from meltygui.core.layout.column_core import ColumnLayout
1015
+
1016
+ # [tint=(0.55, 0.72, 0.95)]
1017
+ folder_icon = f""
1018
+ # [tint=(0.55, 0.72, 0.95)]
1019
+ computer_icon = f""
1020
+ # [tint=(0.55, 0.72, 0.95)]
1021
+ left_pad = 8.0
1022
+ # [tint=(0.55, 0.72, 0.95)]
1023
+ glyph_width = 20.0
1024
+ text_rgba = (0.85, 0.88, 0.92, 1.0)
1025
+ text_col = pack_color(*text_rgba)
1026
+ # [tint=(0.55, 0.72, 0.95)]
1027
+ hover_boost = 0.06
1028
+ # [tint=(0.55, 0.72, 0.95)]
1029
+ hover_alpha = 0.05
1030
+ # [tint=(0.55, 0.72, 0.95)]
1031
+ text_mix = 0.5
1032
+ # [tint=(0.55, 0.72, 0.95)]
1033
+ icon_mix = 0.9
1034
+ hover_wash = pack_color(1.0, 1.0, 1.0, hover_alpha)
1035
+
1036
+ px = Melty.px
1037
+ clear_glows(draw_state) # The current shortcut's retained shadow (see the listing)
1038
+ directory = Path(input_value if input_value else Path.home()).expanduser()
1039
+ draw_list = imgui.get_window_draw_list()
1040
+ mouse_x, mouse_y = imgui.get_mouse_pos()
1041
+ hover_ok = draw_state._bounding_hovered
1042
+ click = ((left_mouse_clicked.x, left_mouse_clicked.y)
1043
+ if (left_mouse_clicked and hasattr(left_mouse_clicked, "x")) else None)
1044
+ chip = px(chip_size) if show_tint_chips else 0.0
1045
+ text_x = px(left_pad) + (chip + px(6) if show_tint_chips else 0.0)
1046
+ meta = file_metadata
1047
+ row_bg = row_tint_bg()
1048
+ # The listing writes what the right-click landed on here each run; the
1049
+ # menu item callables (built here, run by the wrapper's items menu with no
1050
+ # label) read it when picked.
1051
+ menu_target = {"path": str(directory)}
1052
+ menu_items = None
1053
+ if context_menu:
1054
+ menu_items = {label: (lambda _action=action: _action(menu_target["path"]))
1055
+ for label, action in context_menu.items()}
1056
+
1057
+ # The band: from the flow cursor to the bottom of the view.
1058
+ body_top = imgui.get_cursor_screen_pos()[1]
1059
+ body_bottom = draw_state.abs_top + (draw_state.height or px(600))
1060
+ body_height = max(px(120), body_bottom - body_top)
1061
+ columns = ColumnLayout(draw_state, 2, column_edges=column_edges,
1062
+ column_widths=[shortcuts_width, None], column_mins=[110, 200],
1063
+ padding=px(column_gap), padding_y=0, border_color=None)
1064
+
1065
+ result = (False, input_value)
1066
+ picked = None
1067
+ with columns.cell(0, height=body_height) as width:
1068
+ changed_s, picked_s = draw_shortcuts(
1069
+ str(directory), name="shortcuts", file_metadata=file_metadata, width=width, height=body_height,
1070
+ row_height=shortcut_row_height, show_tint_chips=show_tint_chips,
1071
+ chip_size=chip_size, default_tint=default_tint, disable_scroll=True,
1072
+ left_mouse_clicked=left_mouse_clicked)
1073
+ picked = Path(picked_s) if changed_s else None
1074
+ with columns.cell(1, height=body_height) as width:
1075
+ if layout_out is not None:
1076
+ layout_out["listing_left"] = imgui.get_cursor_screen_pos()[0]
1077
+ changed, value = draw_file_listing(str(directory), name="listing", file_metadata=file_metadata, width=width,
1078
+ height=body_height, disable_scroll=False,
1079
+ context_menu=menu_items, menu_target=menu_target,
1080
+ drag_rows=drag_rows, folder_bg_boost=folder_bg_boost,
1081
+ folder_bg_rounding=folder_bg_rounding,
1082
+ show_tint_chips=show_tint_chips, chip_size=chip_size,
1083
+ default_tint=default_tint, type_to_search=type_to_search,
1084
+ show_crumbs=show_crumbs, show_hidden=show_hidden)
1085
+ if changed:
1086
+ result = (True, value)
1087
+ columns.finish()
1088
+
1089
+ if result[0]:
1090
+ return result
1091
+ if picked is not None:
1092
+ request_render()
1093
+ return True, str(picked)
1094
+ if ctrl_up_key_pressed and directory.parent != directory:
1095
+ request_render()
1096
+ return True, str(directory.parent)
1097
+ return False, input_value
1098
+
1099
+
1100
+ @render_func(tint=(0.32, 0.42, 0.54), selectable=False, disable_scroll=True,
1101
+ show_bg=False, shadow=False, determines_height=False)
1102
+ def draw_file_selector(input_value: str | None = None, draw_state=None,
1103
+ selector_state: FileSelectorState = None,
1104
+ escape_key_pressed=False, choose_folder=False,
1105
+ context_menu=None, browse=None, show_hidden=None, file_metadata=None):
1106
+ """Return (True, absolute_path) once when a file is activated.
1107
+
1108
+ Navigation stays here. input_value seeds the initial directory (home
1109
+ by default); later opens remember the last directory. Double-click or
1110
+ Enter selects a file. Cancel / the window close button returns no change.
1111
+ In an OS child, selection closes the window. Pass open_requested=True
1112
+ for one frame to open/reopen it; False leaves its current state alone.
1113
+ Omit open_requested to open immediately on the first call.
1114
+
1115
+ ``choose_folder=True`` makes it a FOLDER picker: files are inert, a
1116
+ "Choose Folder" button beside Cancel returns the directory being
1117
+ shown (double-click still descends). ``context_menu`` = {label:
1118
+ callable(path)} is the listing's right-click menu (the explorer's).
1119
+ ``browse`` = a directory to navigate to — a path or ``(path, token)``
1120
+ — applied once per distinct value; keep passing it.
1121
+ """
1122
+ from meltygui.core.runtime.app import pressed
1123
+ from meltygui.view.control_view import draw_button
1124
+ from meltygui.view.file_view import draw_fast_file_explorer
1125
+
1126
+ if selector_state.directory is None:
1127
+ directory = Path(input_value or Path.home()).expanduser().resolve()
1128
+ selector_state.directory = str(directory if directory.is_dir() else directory.parent)
1129
+ # ``browse``: a directory to show - a path, or ``(path, token)`` where
1130
+ # a new token re-applies the same path - applied once per distinct
1131
+ # value (the settings editor's shortcuts column pick). An OS-child body
1132
+ # runs on its own thread, so the host keeps passing the token rather
1133
+ # than passing it for one frame.
1134
+ if browse != getattr(selector_state, "_browse_applied", None):
1135
+ selector_state._browse_applied = browse
1136
+ target = browse[0] if isinstance(browse, tuple) else browse
1137
+ if target:
1138
+ target = Path(target).expanduser()
1139
+ if target.is_dir():
1140
+ selector_state.directory = str(target)
1141
+ left, top, right, bottom = draw_state.get_content_rect()
1142
+ _trace_browser_size("file-selector-size", draw_state,
1143
+ content=(left, top, right, bottom),
1144
+ explorer=(right - left, max(120, bottom - top - 35)))
1145
+ changed, picked = draw_fast_file_explorer(
1146
+ selector_state.directory, name='files', width=right - left,
1147
+ file_metadata=file_metadata,
1148
+ height=max(120, bottom - top - 35),
1149
+ folder_bg_boost=-0.23, folder_bg_rounding=10.0, context_menu=context_menu,
1150
+ show_hidden=show_hidden)
1151
+ # The explorer's flow cursor includes the full listing height, even
1152
+ # when that listing is clipped/scrolling. Anchor the reserved footer
1153
+ # to the selector's viewport so resizing cannot push it off-window.
1154
+ imgui.set_cursor_screen_pos((left, bottom - 30))
1155
+ if choose_folder:
1156
+ chosen, _ = draw_button(label='Choose Folder', name='choose', show_header=False,
1157
+ width=130, height=25)
1158
+ imgui.same_line()
1159
+ if chosen:
1160
+ draw_state.closed = True
1161
+ return True, selector_state.directory
1162
+ cancelled, _ = draw_button(label='Cancel', name='cancel', show_header=False,
1163
+ width=90, height=25)
1164
+ if cancelled or escape_key_pressed or pressed('escape'):
1165
+ draw_state.closed = True
1166
+ return False, None
1167
+ if changed:
1168
+ path = Path(picked).expanduser().resolve()
1169
+ if path.is_dir():
1170
+ selector_state.directory = str(path)
1171
+ elif path.is_file() and not choose_folder:
1172
+ draw_state.closed = True
1173
+ return True, str(path)
1174
+ return False, None
1175
+
1176
+
1177
+ @render_func()
1178
+ def file_watch_debug(draw_state=None):
1179
+ from meltygui.core.files.file_watch_core import _symbol_index_view
1180
+
1181
+ from meltygui.core.melty import Melty
1182
+ from meltygui.core.melty import FileWatch
1183
+ from meltygui.editor.external_changes import ExternalChanges
1184
+
1185
+ RenderFuncs.draw_function(FileWatch.watch_project_files, icon="",
1186
+ tint=(0, 0, 0, 1), show_bg=False, run_in_thread=True,
1187
+ result_fade_frames=30)
1188
+
1189
+ from meltygui.editor.pending_save import PendingSave
1190
+
1191
+ view_paths = {p: len(dss) for p, dss in FileWatch.path_to_draw_states.items()}
1192
+ tracked = FileWatch.project_tracked
1193
+ all_paths = sorted(set(view_paths) | set(tracked))
1194
+
1195
+ refs_cache, span_cache, snap, index_gen = _symbol_index_view()
1196
+ spans_by_file = defaultdict(list)
1197
+ for (p, start, end), (sig, _usages) in list(span_cache.items()):
1198
+ spans_by_file[p].append(sig)
1199
+
1200
+ _mtimes = {}
1201
+
1202
+ def _mtime(p):
1203
+ if p not in _mtimes:
1204
+ try:
1205
+ _mtimes[p] = os.stat(p).st_mtime
1206
+ except OSError:
1207
+ _mtimes[p] = None
1208
+ return _mtimes[p]
1209
+
1210
+ def _sym_marks(p):
1211
+ """Usage-symbol status for one file: warmer refs cache + span usages."""
1212
+ out = []
1213
+ entry = refs_cache.get(p)
1214
+ if entry is not None:
1215
+ # entry[0] is (mtime, pending_gen); disk-freshness is the mtime
1216
+ # half. A pre-hotswap twin module can still hold old-format bare
1217
+ # float entries - treat those as the mtime too.
1218
+ _sig = entry[0]
1219
+ fresh = (_sig[0] if isinstance(_sig, tuple) else _sig) == _mtime(p)
1220
+ out.append(f"refs {len(entry[1])}" if fresh else "refs STALE")
1221
+ elif p in snap:
1222
+ out.append("refs evicted") # counted by the warmer, but cold
1223
+ else:
1224
+ out.append("no idx") # never indexed (no live module)
1225
+ sigs = spans_by_file.get(p)
1226
+ if sigs:
1227
+ try:
1228
+ pg = PendingSave.pending_gen_for(Path(p))
1229
+ ok = sum(1 for s in sigs
1230
+ if s and s[0] == _mtime(p) and s[1] == pg
1231
+ and (s[2] or s[3] == index_gen))
1232
+ except Exception:
1233
+ ok = "?"
1234
+ out.append(f"usages {ok}/{len(sigs)} fresh")
1235
+ return out
1236
+
1237
+ refs_fresh = sum(1 for p, entry in refs_cache.items()
1238
+ if entry is not None and entry[0] == _mtime(p))
1239
+ cached = sum(1 for p in all_paths if p in Melty.code_cache)
1240
+ lines = [
1241
+ f"watched dirs: {len(FileWatch._watched_dirs)} "
1242
+ f"files known: {len(all_paths)} (project-tracked {len(tracked)}, "
1243
+ f"view-watched {len(view_paths)})",
1244
+ f"baselined in code_cache: {cached}/{len(all_paths)} "
1245
+ f"external drift tracked: {len(ExternalChanges.originals)}",
1246
+ f"symbol index: gen {index_gen} refs cached {len(refs_cache)} file(s) "
1247
+ f"({refs_fresh} fresh) span usages {len(span_cache)} "
1248
+ f"across {len(spans_by_file)} file(s)",
1249
+ "",
1250
+ ]
1251
+
1252
+ by_dir = defaultdict(list)
1253
+ for p in all_paths:
1254
+ by_dir[str(Path(p).parent)].append(p)
1255
+
1256
+ # Dirs watched but currently holding no tracked file still matter (events
1257
+ # within them can auto-track new .py files) - show them at the end.
1258
+ empty_dirs = sorted(d for d in FileWatch._watched_dirs if d not in by_dir)
1259
+
1260
+ for d in sorted(by_dir):
1261
+ watched = "" if d in FileWatch._watched_dirs else " [dir NOT scheduled!]"
1262
+ lines.append(f"{d}{watched}")
1263
+ for p in by_dir[d]:
1264
+ marks = []
1265
+ if p in Melty.code_cache:
1266
+ marks.append("cached")
1267
+ else:
1268
+ marks.append("UNCACHED — edits invisible")
1269
+ n = view_paths.get(p)
1270
+ if n:
1271
+ marks.append(f"{n} view{'s' if n > 1 else ''}")
1272
+ if p in tracked:
1273
+ marks.append("tracked")
1274
+ if p in ExternalChanges.originals:
1275
+ marks.append("DRIFT")
1276
+ marks.extend(_sym_marks(p))
1277
+ lines.append(f" {Path(p).name} [{', '.join(marks)}]")
1278
+ if empty_dirs:
1279
+ lines.append("")
1280
+ lines.append(f"watched dirs with no known files ({len(empty_dirs)}):")
1281
+ lines.extend(f" {d}" for d in empty_dirs)
1282
+
1283
+ RenderFuncs.draw_text("\n".join(lines), show_name=True, name="watch status")
1284
+
1285
+
1286
+ @render_func(tint=(0.32, 0.42, 0.54), auto_resize=False, selectable=False)
1287
+ def render_file_tree(input_value=None, draw_state=None,
1288
+ file_tree_state: FileTreeState = None, root=ROOT, file_metadata=None,
1289
+ left_mouse_down=False, left_mouse_double_clicked=False,
1290
+ escape_key_pressed=False, **kwargs):
1291
+ # Geometry authored at ui_scale 1.0 — scaled through Melty.px per frame.
1292
+ # [tint=(0.55, 0.72, 0.95)]
1293
+ from meltygui.core.windowing.glfw_utils import request_render
1294
+ from meltygui.view.header_view import flat_button
1295
+ from meltygui.core.cache.tile_cache import add_shadow
1296
+ from meltygui.core.input.drag_drop_core import DragDrop
1297
+ from meltygui.core.layout.header_runtime import _brightness_clamp_fn
1298
+ from meltygui.model.import_graph_model import start_build
1299
+ from meltygui.core.files.file_tree_core import _apply_row_drop
1300
+ from meltygui.core.files.file_tree_core import _flatten_ordered
1301
+ from meltygui.core.files.file_tree_core import _tint_of
1302
+ import meltygui.model.import_graph_model as file_graph
1303
+
1304
+ row_height = 20.0
1305
+ # Per nesting level, in px at ui_scale 1.0 — kept tiny so a deep tree
1306
+ # doesn't march off to the right (Lukas 09-04: 2 px, no stair).
1307
+ # [tint=(0.55, 0.72, 0.95)]
1308
+ indent_per_level = 2.0
1309
+ left_pad = 6.0
1310
+ glyph_width = 12.0
1311
+ # Row backgrounds are the files' own tints (FileMeta) run through the
1312
+ # SAME colour pipeline as the editor's active tabs (flat_button: the
1313
+ # style manager's mix under Toggles.CodeEditor.tab_active_bg_* + the
1314
+ # brightness clamp), so every row bg stays dark enough for light text.
1315
+ # [tint=(0.55, 0.72, 0.95)]
1316
+ bg_theme_factor = 0.1
1317
+ # With an import graph built every file rises with its USAGE — importer
1318
+ # count on the graph's log scale (ImportGraph.usage, 0..1) — through a
1319
+ # drop shadow (add_shadow depth lift, the editor's active-tab mechanism)
1320
+ # AND its name's font size, so the files everything depends on pop out
1321
+ # of the tree. While a file is SELECTED only it and its highlighted files
1322
+ # cast: the selection and its IMPORTERS are RAISED by highlight_lift, its
1323
+ # IMPORTS are RECESSED by the same amount (a negative add_shadow offset
1324
+ # carves the row in, the surroundings cast into it), every other file
1325
+ # drops flat; the font keeps following usage throughout. A file that is
1326
+ # both reads as an importer.
1327
+ # [tint=(0.55, 0.72, 0.95)]
1328
+ usage_lift = 6.0
1329
+ # [tint=(0.55, 0.72, 0.95)]
1330
+ highlight_lift = 3.0
1331
+ # Font scale range over usage (clamped so the biggest still fits the
1332
+ # row); a file NOBODY imports is greyed out.
1333
+ # [tint=(0.55, 0.72, 0.95)]
1334
+ usage_font_scale_min = 0.9
1335
+ # [tint=(0.55, 0.72, 0.95)]
1336
+ usage_font_scale_max = 1.3
1337
+ # [tint=(0.55, 0.72, 0.95)]
1338
+ unused_text = (0.55, 0.57, 0.6, 0.6)
1339
+ # Import-graph highlights wear the SELECTED file's own tint: what it
1340
+ # imports in the tint itself, what imports it in a lighter, washed-out
1341
+ # variant (importer_value_boost / importer_saturation), both blended
1342
+ # for a file in both directions. A folder holding hidden hits gets the
1343
+ # same mark at reduced alpha. An unpainted selection uses the fallback.
1344
+ # [tint=(0.55, 0.72, 0.95)]
1345
+ highlight_fallback_tint = (0.55, 0.72, 0.95)
1346
+ # [tint=(0.55, 0.72, 0.95)]
1347
+ importer_value_boost = 0.35
1348
+ # [tint=(0.55, 0.72, 0.95)]
1349
+ importer_saturation = 0.45
1350
+ # [tint=(0.55, 0.72, 0.95)]
1351
+ highlight_bar_width = 3.0
1352
+ # [tint=(0.55, 0.72, 0.95)]
1353
+ toolbar_height = 26.0
1354
+
1355
+ state = file_tree_state
1356
+ px = Melty.px
1357
+ row_h, indent, pad, glyph_w = px(row_height), px(indent_per_level), px(left_pad), px(glyph_width)
1358
+
1359
+ dl = imgui.get_window_draw_list()
1360
+ cw = draw_state.content_width or (draw_state.width or 240)
1361
+
1362
+ # ── toolbar: the import-graph build button + status ───────────────────
1363
+ build = state._build
1364
+ if build is not None and not build.running:
1365
+ if build.result is not None:
1366
+ state._graph = build.result
1367
+ state._build = None
1368
+ draw_state.invalidate()
1369
+ if state._graph is None and file_graph.current() is not None:
1370
+ state._graph = file_graph.current() # built by the graph graph
1371
+ draw_state.invalidate()
1372
+ if flat_button("Build import graph##file_tree", draw_state,
1373
+ view_id="file_tree_build_graph", height=px(toolbar_height) - px(4),
1374
+ event="left_mouse_down"):
1375
+ if state._build is None:
1376
+ state._build = start_build(Path(root))
1377
+ request_render()
1378
+ status_x, status_y = imgui.get_item_rect_max().x + px(8), imgui.get_item_rect_min().y + px(4)
1379
+ graph = state._graph
1380
+ if build is not None and build.running:
1381
+ status = "building…"
1382
+ elif build is not None and build.error:
1383
+ status = build.error
1384
+ elif graph is not None:
1385
+ status = f"{graph.file_count} files, {graph.edge_count} imports, {graph.seconds:.1f}s"
1386
+ if graph.errors:
1387
+ status += f", {len(graph.errors)} unparsed"
1388
+ else:
1389
+ status = "no graph yet"
1390
+ dl.add_text(status_x, status_y, pack_color(0.75, 0.78, 0.82, 1.0), status)
1391
+ imgui.set_cursor_pos_y(imgui.get_cursor_pos_y() + px(4))
1392
+ x0, y0 = imgui.get_cursor_screen_pos()
1393
+
1394
+ # Esc (while the tree is open) clears the selection - and with it the
1395
+ # highlights and the fixed-lift shadows.
1396
+ if escape_key_pressed and state.selected is not None:
1397
+ state.select(None)
1398
+ request_render()
1399
+
1400
+ # Highlight sets for the selected file (files only - folders are marked
1401
+ # when they hold a hit that isn't visible).
1402
+ imports, importers = set(), set()
1403
+ selected_path = state.selected_path
1404
+ if graph is not None and selected_path is not None:
1405
+ imports = graph.imports_of(selected_path)
1406
+ importers = graph.importers_of(selected_path)
1407
+ hits = imports | importers
1408
+
1409
+ # Row order comes from the file_meta dict: a path's key position is its
1410
+ # rank among its siblings (drag-reorder rewrites those positions).
1411
+ meta = file_metadata
1412
+ position = ({k: i for i, k in enumerate(meta)} if meta is not None else {})
1413
+ rows = _flatten_ordered(Path(root), state.expanded, meta, position)
1414
+ # One dummy reports the total height so the container scrolls normally.
1415
+ # The scroll clamp is content_height - clipped_height, but clipped_height
1416
+ # spans the WHOLE window (header included) while the rows start below the
1417
+ # header + toolbar - pad the reported height by that top inset (the
1418
+ # fast_dock rule) or the last row can never scroll fully into view.
1419
+ top_inset = (y0 + draw_state.scroll_offset[1]) - draw_state.abs_top
1420
+ imgui.dummy(cw, max(1.0, len(rows) * row_h + max(0.0, top_inset)))
1421
+ selected_tint = _tint_of(meta, selected_path) if selected_path is not None else None
1422
+ imports_tint = selected_tint or highlight_fallback_tint
1423
+ hue, saturation, value = colorsys.rgb_to_hsv(*imports_tint)
1424
+ importers_tint = colorsys.hsv_to_rgb(hue, saturation * importer_saturation,
1425
+ min(1.0, value + importer_value_boost))
1426
+
1427
+ mx, my = imgui.get_mouse_pos()
1428
+ hover_ok = draw_state._bounding_hovered
1429
+ click = ((left_mouse_down.x, left_mouse_down.y)
1430
+ if (left_mouse_down and hasattr(left_mouse_down, "x")) else None)
1431
+ dclick = ((left_mouse_double_clicked.x, left_mouse_double_clicked.y)
1432
+ if (left_mouse_double_clicked and hasattr(left_mouse_double_clicked, "x")) else None)
1433
+ clip = getattr(draw_state, "abs_clip_rect", None)
1434
+
1435
+ text_col = pack_color(0.92, 0.92, 0.92, 1.0)
1436
+ unused_col = pack_color(*unused_text)
1437
+ font_size = imgui.get_font_size()
1438
+ # The scaled name must still fit the row.
1439
+ font_scale_max = min(usage_font_scale_max, row_h / max(font_size, 1.0))
1440
+ style_manager = Melty.style_manager
1441
+ brightness_clamp = _brightness_clamp_fn()
1442
+ bg_memo = {}
1443
+
1444
+ def row_bg(tint):
1445
+ """The tab bar's bg colour for `tint` (memoized per body run)."""
1446
+ bg = bg_memo.get(tint)
1447
+ if bg is None:
1448
+ mixed = style_manager.make_color_rgb(
1449
+ tint[0], tint[1], tint[2],
1450
+ value=Toggles.CodeEditor.tab_active_bg_brightness,
1451
+ factor=bg_theme_factor,
1452
+ saturation_scale=Toggles.CodeEditor.tab_active_bg_saturation,
1453
+ alpha=1.0)
1454
+ mixed = brightness_clamp(mixed[0], mixed[1], mixed[2], 0.0,
1455
+ Toggles.CodeEditor.tab_active_bg_max_brightness)
1456
+ bg = bg_memo[tint] = pack_color(mixed[0], mixed[1], mixed[2], 1.0)
1457
+ return bg
1458
+ hover_col = pack_color(1.0, 1.0, 1.0, 0.08)
1459
+ select_col = pack_color(0.4, 0.6, 0.9, 0.35)
1460
+
1461
+ imports_col = pack_color(*imports_tint, 0.9)
1462
+ importers_col = pack_color(*importers_tint, 0.9)
1463
+ both_col = pack_color(*[(a + b) / 2 for a, b in zip(imports_tint, importers_tint)], 0.9)
1464
+ imports_wash = pack_color(*imports_tint, 0.14)
1465
+ importers_wash = pack_color(*importers_tint, 0.14)
1466
+ folder_alpha = 0.45
1467
+
1468
+ def highlight_of(p):
1469
+ """(bar colour, wash colour, alpha scale) for a row, or None."""
1470
+ if not hits:
1471
+ return None
1472
+ if p.is_dir():
1473
+ inside = [h for h in hits if p in h.parents]
1474
+ if not inside:
1475
+ return None
1476
+ in_imports = any(h in imports for h in inside)
1477
+ in_importers = any(h in importers for h in inside)
1478
+ scale = folder_alpha
1479
+ else:
1480
+ in_imports, in_importers = p in imports, p in importers
1481
+ if not (in_imports or in_importers):
1482
+ return None
1483
+ scale = 1.0
1484
+ if in_imports and in_importers:
1485
+ return both_col, imports_wash, scale
1486
+ if in_imports:
1487
+ return imports_col, imports_wash, scale
1488
+ return importers_col, importers_wash, scale
1489
+
1490
+ def paint_row(draw_list, rx, ry, p, depth, tint, hovered, selected, shadow=True):
1491
+ """One row's visuals at (rx, ry): tint bg, hover/select wash, folder
1492
+ chevron, name. Shared by the inline rows and the drag ghost (which
1493
+ rides the overlay list outside the shadow marks' clip: shadow=False)."""
1494
+ # The bg is indented with the text: it starts at the row's chevron
1495
+ # column and runs to the right edge.
1496
+ x = rx + pad + depth * indent
1497
+ usage = None # None if no graph / a folder
1498
+ if graph is not None and not p.is_dir():
1499
+ usage = graph.usage(p)
1500
+ if shadow and usage is not None:
1501
+ if selected_path is not None:
1502
+ if p == selected_path or p in importers:
1503
+ lift = highlight_lift
1504
+ elif p in imports:
1505
+ lift = -highlight_lift
1506
+ else:
1507
+ lift = 0.0
1508
+ else:
1509
+ lift = usage_lift * usage
1510
+ if lift != 0.0:
1511
+ add_shadow((x, ry, rx + cw - x, row_h), offset=lift, corner_radius=0.0)
1512
+ if tint is not None:
1513
+ draw_list.add_rect_filled(x, ry, rx + cw, ry + row_h, row_bg(tint))
1514
+ if selected:
1515
+ draw_list.add_rect_filled(x, ry, rx + cw, ry + row_h, select_col)
1516
+ if hovered:
1517
+ draw_list.add_rect_filled(x, ry, rx + cw, ry + row_h, hover_col)
1518
+ mark = highlight_of(p) if shadow else None
1519
+ if mark is not None:
1520
+ bar_col, wash_col, scale = mark
1521
+ if scale < 1.0:
1522
+ bar_col = with_alpha(bar_col, 0.9 * scale)
1523
+ wash_col = with_alpha(wash_col, 0.14 * scale)
1524
+ draw_list.add_rect_filled(x, ry, rx + cw, ry + row_h, wash_col)
1525
+ draw_list.add_rect_filled(x, ry, x + px(highlight_bar_width), ry + row_h, bar_col)
1526
+ if p.is_dir():
1527
+ cx, cy = x + px(4.0), ry + row_h / 2
1528
+ radius = px(4.0)
1529
+ tri = ([(cx - radius + 1, cy - radius), (cx + radius - 1, cy), (cx - radius + 1, cy + radius)]
1530
+ if not state.is_expanded(p)
1531
+ else [(cx - radius, cy - radius + 1), (cx + radius, cy - radius + 1), (cx, cy + radius - 1)])
1532
+ draw_list.add_triangle_filled(*tri[0], *tri[1], *tri[2], text_col)
1533
+ name_col = unused_col if usage == 0.0 else text_col
1534
+ if usage is not None:
1535
+ # set_window_font_scale retargets the draw list's font size at
1536
+ # once (imgui's AddText uses g.Fonts), so the name scales
1537
+ # without a second font face; reset right after.
1538
+ scale = usage_font_scale_min + (font_scale_max - usage_font_scale_min) * usage
1539
+ imgui.set_window_font_scale(scale)
1540
+ draw_list.add_text(x + glyph_w, ry + (row_h - font_size * scale) * 0.5, name_col, p.name)
1541
+ imgui.set_window_font_scale(1.0)
1542
+ else:
1543
+ draw_list.add_text(x + glyph_w, ry + px(2.0), name_col, p.name)
1544
+
1545
+ # on_drag call order == index into `visible` (DropEvent indices count
1546
+ # on_drag calls, so only rows that were used as handles are indexed).
1547
+ visible = []
1548
+ for i, (p, depth) in enumerate(rows):
1549
+ ry0 = y0 + i * row_h
1550
+ ry1 = ry0 + row_h
1551
+
1552
+ def in_row(pt):
1553
+ return pt is not None and x0 <= pt[0] <= x0 + cw and ry0 <= pt[1] <= ry1
1554
+
1555
+ if p.is_dir():
1556
+ if in_row(click):
1557
+ state.toggle(p)
1558
+ request_render()
1559
+ else:
1560
+ if in_row(click):
1561
+ state.select(p)
1562
+ request_render()
1563
+ if in_row(dclick):
1564
+ state.open_file(p)
1565
+
1566
+ if clip is not None and (ry1 < clip[1] or ry0 > clip[3]):
1567
+ continue
1568
+
1569
+ visible.append((p, depth))
1570
+ tint = _tint_of(meta, p)
1571
+ # Immediate-mode DragDrop (the editor tab bar's model): the row rect
1572
+ # is its own drag handle. While THIS row is the active drag - on_drag
1573
+ # has parked the cursor at the drop position on the overlay list -
1574
+ # paint the same row there and leave the inline slot empty (the
1575
+ # home/cancel target; DragDrop draws the feedback lines).
1576
+ drag = DragDrop.on_drag((x0, ry0, x0 + cw, ry1), key=str(p),
1577
+ draw_state=draw_state)
1578
+ if drag:
1579
+ paint_row(drag.draw_list, drag.x, drag.y, p, depth, tint,
1580
+ hovered=False, selected=False, shadow=False)
1581
+ DragDrop.end_drag()
1582
+ continue
1583
+
1584
+ hovered = hover_ok and x0 <= mx <= x0 + cw and ry0 <= my <= ry1
1585
+ paint_row(dl, x0, ry0, p, depth, tint, hovered, p == selected_path)
1586
+
1587
+ # A landed drop reorders within ONE folder: the slot must be beside a
1588
+ # sibling of the dragged row (before the row at insert_index, or after
1589
+ # the row above it); anything else - another folder, a cross-collection
1590
+ # kind - is ignored.
1591
+ drop = DragDrop.on_drop(horizontal=False, draw_state=draw_state)
1592
+ if drop is not None and drop.kind == "reorder" and drop.index is not None:
1593
+ dragged = visible[drop.index][0] if 0 <= drop.index < len(visible) else None
1594
+ if dragged is not None and _apply_row_drop(meta, dragged, visible,
1595
+ drop.insert_index, position):
1596
+ draw_state.invalidate()
1597
+ request_render()
1598
+
1599
+ return False, None