iCode-TUI 0.29.1__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.
- chrys/__init__.py +16 -0
- chrys/app/__init__.py +3 -0
- chrys/app/acp/__init__.py +3 -0
- chrys/app/acp/bridge.py +664 -0
- chrys/app/acp/content.py +132 -0
- chrys/app/acp/doc/frontend-api.md +425 -0
- chrys/app/acp/history.py +585 -0
- chrys/app/acp/server.py +1796 -0
- chrys/app/acp/session_manager.py +1815 -0
- chrys/app/acp/tool_status.py +45 -0
- chrys/app/cli/__init__.py +3 -0
- chrys/app/cli/acp.py +178 -0
- chrys/app/cli/app.py +165 -0
- chrys/app/cli/approvals.py +101 -0
- chrys/app/cli/headless.py +223 -0
- chrys/app/cli/launch_cwd.py +42 -0
- chrys/app/cli/profiles.py +298 -0
- chrys/app/cli/progress.py +857 -0
- chrys/app/cli/run.py +425 -0
- chrys/app/cli/serve.py +1593 -0
- chrys/app/cli/tool_summary.py +225 -0
- chrys/app/cli/trajectory.py +201 -0
- chrys/app/cli/workflow.py +601 -0
- chrys/app/features/__init__.py +3 -0
- chrys/app/features/buddy/__init__.py +3 -0
- chrys/app/features/buddy/actions.py +68 -0
- chrys/app/features/buddy/animation.py +76 -0
- chrys/app/features/buddy/commands.py +196 -0
- chrys/app/features/buddy/docs/design.md +187 -0
- chrys/app/features/buddy/hatchery.py +80 -0
- chrys/app/features/buddy/lifecycle.py +41 -0
- chrys/app/features/buddy/model.py +265 -0
- chrys/app/features/buddy/pixel_renderer.py +113 -0
- chrys/app/features/buddy/pixel_sprites.py +279 -0
- chrys/app/features/buddy/portrait.py +76 -0
- chrys/app/features/buddy/progression.py +58 -0
- chrys/app/features/buddy/replies.py +191 -0
- chrys/app/features/buddy/sprites/alien.toml +70 -0
- chrys/app/features/buddy/sprites/axolotl.toml +71 -0
- chrys/app/features/buddy/sprites/bat.toml +69 -0
- chrys/app/features/buddy/sprites/bee.toml +69 -0
- chrys/app/features/buddy/sprites/cactus.toml +73 -0
- chrys/app/features/buddy/sprites/capybara.toml +70 -0
- chrys/app/features/buddy/sprites/cat.toml +69 -0
- chrys/app/features/buddy/sprites/crab.toml +70 -0
- chrys/app/features/buddy/sprites/dragon.toml +71 -0
- chrys/app/features/buddy/sprites/duck.toml +69 -0
- chrys/app/features/buddy/sprites/fox.toml +69 -0
- chrys/app/features/buddy/sprites/frog.toml +70 -0
- chrys/app/features/buddy/sprites/ghost.toml +70 -0
- chrys/app/features/buddy/sprites/goose.toml +69 -0
- chrys/app/features/buddy/sprites/jelly.toml +70 -0
- chrys/app/features/buddy/sprites/llama.toml +70 -0
- chrys/app/features/buddy/sprites/mushroom.toml +69 -0
- chrys/app/features/buddy/sprites/octopus.toml +70 -0
- chrys/app/features/buddy/sprites/owl.toml +70 -0
- chrys/app/features/buddy/sprites/panda.toml +71 -0
- chrys/app/features/buddy/sprites/penguin.toml +69 -0
- chrys/app/features/buddy/sprites/rabbit.toml +70 -0
- chrys/app/features/buddy/sprites/robot.toml +72 -0
- chrys/app/features/buddy/sprites/shark.toml +70 -0
- chrys/app/features/buddy/sprites/snail.toml +69 -0
- chrys/app/features/buddy/sprites/snake.toml +69 -0
- chrys/app/features/buddy/sprites/tabby.toml +69 -0
- chrys/app/features/buddy/sprites/turtle.toml +69 -0
- chrys/app/features/buddy/store.py +143 -0
- chrys/app/features/session_title/__init__.py +7 -0
- chrys/app/features/session_title/updater.py +582 -0
- chrys/app/installer.py +651 -0
- chrys/app/parsing.py +17 -0
- chrys/app/tui/__init__.py +3 -0
- chrys/app/tui/app.py +1287 -0
- chrys/app/tui/behaviors/__init__.py +3 -0
- chrys/app/tui/behaviors/click_outside_dismiss.py +90 -0
- chrys/app/tui/behaviors/insert_clipboard.py +73 -0
- chrys/app/tui/behaviors/right_click_copy.py +146 -0
- chrys/app/tui/binding_display.py +139 -0
- chrys/app/tui/buddy_messages.py +17 -0
- chrys/app/tui/buddy_reply.py +86 -0
- chrys/app/tui/chrys.tcss +549 -0
- chrys/app/tui/clipboard.py +109 -0
- chrys/app/tui/copy_messages.py +10 -0
- chrys/app/tui/i18n.py +230 -0
- chrys/app/tui/language.py +58 -0
- chrys/app/tui/notifications/__init__.py +12 -0
- chrys/app/tui/notifications/drivers.py +180 -0
- chrys/app/tui/notifications/service.py +126 -0
- chrys/app/tui/notifications/settings.py +141 -0
- chrys/app/tui/screens/__init__.py +3 -0
- chrys/app/tui/screens/agents/__init__.py +8 -0
- chrys/app/tui/screens/agents/agent_draft_store.py +937 -0
- chrys/app/tui/screens/agents/config.py +2142 -0
- chrys/app/tui/screens/agents/config.tcss +169 -0
- chrys/app/tui/screens/agents/panels/__init__.py +3 -0
- chrys/app/tui/screens/agents/panels/acp.py +1176 -0
- chrys/app/tui/screens/agents/panels/basic.py +497 -0
- chrys/app/tui/screens/agents/panels/compaction.py +231 -0
- chrys/app/tui/screens/agents/panels/config_card.py +218 -0
- chrys/app/tui/screens/agents/panels/instructions.py +91 -0
- chrys/app/tui/screens/agents/panels/markdown_report.py +76 -0
- chrys/app/tui/screens/agents/panels/mcp.py +2262 -0
- chrys/app/tui/screens/agents/panels/memory.py +684 -0
- chrys/app/tui/screens/agents/panels/path_entry.py +227 -0
- chrys/app/tui/screens/agents/panels/skills.py +806 -0
- chrys/app/tui/screens/agents/panels/subagents.py +652 -0
- chrys/app/tui/screens/agents/panels/tools.py +205 -0
- chrys/app/tui/screens/agents/picker.py +103 -0
- chrys/app/tui/screens/agents/skill_paths.py +38 -0
- chrys/app/tui/screens/agents/validation_messages.py +452 -0
- chrys/app/tui/screens/dialogs/__init__.py +35 -0
- chrys/app/tui/screens/dialogs/agent_load.py +834 -0
- chrys/app/tui/screens/dialogs/agent_load.tcss +78 -0
- chrys/app/tui/screens/dialogs/app_mode.py +55 -0
- chrys/app/tui/screens/dialogs/approval/__init__.py +15 -0
- chrys/app/tui/screens/dialogs/approval/approval.tcss +311 -0
- chrys/app/tui/screens/dialogs/approval/bodies/__init__.py +3 -0
- chrys/app/tui/screens/dialogs/approval/bodies/file_edit.py +210 -0
- chrys/app/tui/screens/dialogs/approval/bodies/sub_agent.py +82 -0
- chrys/app/tui/screens/dialogs/approval/body.py +110 -0
- chrys/app/tui/screens/dialogs/approval/dialog.py +565 -0
- chrys/app/tui/screens/dialogs/approval/mode.py +105 -0
- chrys/app/tui/screens/dialogs/ask_user.py +148 -0
- chrys/app/tui/screens/dialogs/ask_user.tcss +62 -0
- chrys/app/tui/screens/dialogs/base.py +84 -0
- chrys/app/tui/screens/dialogs/confirm.py +184 -0
- chrys/app/tui/screens/dialogs/confirm.tcss +46 -0
- chrys/app/tui/screens/dialogs/connection_test.py +183 -0
- chrys/app/tui/screens/dialogs/connection_test.tcss +92 -0
- chrys/app/tui/screens/dialogs/diagram.py +102 -0
- chrys/app/tui/screens/dialogs/diagram.tcss +24 -0
- chrys/app/tui/screens/dialogs/editor.py +378 -0
- chrys/app/tui/screens/dialogs/editor.tcss +132 -0
- chrys/app/tui/screens/dialogs/file_picker.py +582 -0
- chrys/app/tui/screens/dialogs/file_picker.tcss +78 -0
- chrys/app/tui/screens/dialogs/fork_session.py +219 -0
- chrys/app/tui/screens/dialogs/fork_session.tcss +75 -0
- chrys/app/tui/screens/dialogs/image_compression.py +102 -0
- chrys/app/tui/screens/dialogs/image_compression.tcss +28 -0
- chrys/app/tui/screens/dialogs/man_page.py +172 -0
- chrys/app/tui/screens/dialogs/man_page.tcss +39 -0
- chrys/app/tui/screens/dialogs/new_folder.py +277 -0
- chrys/app/tui/screens/dialogs/new_folder.tcss +51 -0
- chrys/app/tui/screens/dialogs/notice.tcss +14 -0
- chrys/app/tui/screens/dialogs/prompt_cache.py +144 -0
- chrys/app/tui/screens/dialogs/prompt_cache.tcss +38 -0
- chrys/app/tui/screens/dialogs/runtime_details.py +371 -0
- chrys/app/tui/screens/dialogs/runtime_details.tcss +49 -0
- chrys/app/tui/screens/dialogs/session_title.py +110 -0
- chrys/app/tui/screens/dialogs/session_title.tcss +43 -0
- chrys/app/tui/screens/dialogs/tool_view.py +207 -0
- chrys/app/tui/screens/dialogs/tool_view.tcss +43 -0
- chrys/app/tui/screens/dialogs/vision_unsupported.py +122 -0
- chrys/app/tui/screens/dialogs/vision_unsupported.tcss +35 -0
- chrys/app/tui/screens/dialogs/workflow_confirm.py +91 -0
- chrys/app/tui/screens/dialogs/workflow_confirm.tcss +17 -0
- chrys/app/tui/screens/dialogs/workflow_input.py +258 -0
- chrys/app/tui/screens/dialogs/workflow_input.tcss +73 -0
- chrys/app/tui/screens/dialogs/workflow_node.py +462 -0
- chrys/app/tui/screens/dialogs/workflow_node.tcss +41 -0
- chrys/app/tui/screens/dialogs/workflow_picker.py +46 -0
- chrys/app/tui/screens/dialogs/workflow_result.py +136 -0
- chrys/app/tui/screens/dialogs/workflow_result.tcss +14 -0
- chrys/app/tui/screens/diff/__init__.py +22 -0
- chrys/app/tui/screens/diff/rollback_modal.py +1381 -0
- chrys/app/tui/screens/diff/rollback_modal.tcss +248 -0
- chrys/app/tui/screens/diff/screen.py +1532 -0
- chrys/app/tui/screens/diff/screen.tcss +104 -0
- chrys/app/tui/screens/guides/__init__.py +7 -0
- chrys/app/tui/screens/guides/_docs/en/guides/configuration/agents.md +178 -0
- chrys/app/tui/screens/guides/_docs/en/guides/configuration/approval.md +117 -0
- chrys/app/tui/screens/guides/_docs/en/guides/configuration/chat-options.md +276 -0
- chrys/app/tui/screens/guides/_docs/en/guides/configuration/compaction.md +42 -0
- chrys/app/tui/screens/guides/_docs/en/guides/configuration/memory.md +48 -0
- chrys/app/tui/screens/guides/_docs/en/guides/configuration/models.md +158 -0
- chrys/app/tui/screens/guides/_docs/en/guides/configuration/settings.md +210 -0
- chrys/app/tui/screens/guides/_docs/en/guides/configuration/themes.md +61 -0
- chrys/app/tui/screens/guides/_docs/en/guides/configuration/tools.md +60 -0
- chrys/app/tui/screens/guides/_docs/en/guides/configuration/web-tools.md +286 -0
- chrys/app/tui/screens/guides/_docs/en/guides/daily-use/sessions.md +77 -0
- chrys/app/tui/screens/guides/_docs/en/guides/daily-use/workspaces.md +51 -0
- chrys/app/tui/screens/guides/_docs/en/guides/extensions/external-acp-agents.md +126 -0
- chrys/app/tui/screens/guides/_docs/en/guides/extensions/hooks.md +167 -0
- chrys/app/tui/screens/guides/_docs/en/guides/extensions/mcp.md +95 -0
- chrys/app/tui/screens/guides/_docs/en/guides/extensions/skills.md +175 -0
- chrys/app/tui/screens/guides/_docs/en/guides/running/icode-acp.md +61 -0
- chrys/app/tui/screens/guides/_docs/en/guides/running/icode-run.md +190 -0
- chrys/app/tui/screens/guides/_docs/en/guides/running/icode-serve.md +239 -0
- chrys/app/tui/screens/guides/_docs/en/guides/running/workflows.md +664 -0
- chrys/app/tui/screens/guides/_docs/en/reference/agent-profile.md +548 -0
- chrys/app/tui/screens/guides/_docs/en/reference/hooks.md +602 -0
- chrys/app/tui/screens/guides/_docs/en/reference/opentelemetry.md +177 -0
- chrys/app/tui/screens/guides/_docs/en/reference/settings.md +245 -0
- chrys/app/tui/screens/guides/_docs/en/reference/themes.md +149 -0
- chrys/app/tui/screens/guides/_docs/en/reference/tool-kinds-and-names.md +58 -0
- chrys/app/tui/screens/guides/_docs/en/reference/tui-slash-commands.md +76 -0
- chrys/app/tui/screens/guides/_docs/en/reference/workflows.md +1006 -0
- chrys/app/tui/screens/guides/_docs/en/start/getting-started.md +170 -0
- chrys/app/tui/screens/guides/_docs/en/start/what-is-icode.md +34 -0
- chrys/app/tui/screens/guides/_docs/index.yaml +100 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/agents.md +178 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/approval.md +117 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/chat-options.md +276 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/compaction.md +42 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/memory.md +48 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/models.md +158 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/settings.md +210 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/themes.md +61 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/tools.md +60 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/web-tools.md +286 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/daily-use/sessions.md +77 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/daily-use/workspaces.md +51 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/extensions/external-acp-agents.md +126 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/extensions/hooks.md +167 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/extensions/mcp.md +95 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/extensions/skills.md +175 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/running/icode-acp.md +61 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/running/icode-run.md +190 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/running/icode-serve.md +239 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/running/workflows.md +664 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/reference/agent-profile.md +548 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/reference/hooks.md +602 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/reference/opentelemetry.md +177 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/reference/settings.md +245 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/reference/themes.md +149 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/reference/tool-kinds-and-names.md +58 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/reference/tui-slash-commands.md +76 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/reference/workflows.md +1005 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/start/getting-started.md +170 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/start/what-is-icode.md +34 -0
- chrys/app/tui/screens/guides/index.py +215 -0
- chrys/app/tui/screens/guides/reader.py +173 -0
- chrys/app/tui/screens/guides/screen.py +442 -0
- chrys/app/tui/screens/guides/screen.tcss +52 -0
- chrys/app/tui/screens/logs/__init__.py +7 -0
- chrys/app/tui/screens/logs/screen.py +504 -0
- chrys/app/tui/screens/logs/screen.tcss +45 -0
- chrys/app/tui/screens/main/__init__.py +7 -0
- chrys/app/tui/screens/main/action_availability.py +30 -0
- chrys/app/tui/screens/main/buddy_command.py +143 -0
- chrys/app/tui/screens/main/commands.py +1073 -0
- chrys/app/tui/screens/main/config_actions.py +464 -0
- chrys/app/tui/screens/main/copy_actions.py +117 -0
- chrys/app/tui/screens/main/dialog_controllers.py +826 -0
- chrys/app/tui/screens/main/dialog_gateway.py +166 -0
- chrys/app/tui/screens/main/diff_controller.py +478 -0
- chrys/app/tui/screens/main/engine_read_model.py +184 -0
- chrys/app/tui/screens/main/event_handlers.py +2014 -0
- chrys/app/tui/screens/main/input_flow.py +241 -0
- chrys/app/tui/screens/main/live_diff.py +244 -0
- chrys/app/tui/screens/main/main_screen_presenter.py +295 -0
- chrys/app/tui/screens/main/model_indicator.py +217 -0
- chrys/app/tui/screens/main/navigation.py +343 -0
- chrys/app/tui/screens/main/ports.py +571 -0
- chrys/app/tui/screens/main/recent_dirs.py +123 -0
- chrys/app/tui/screens/main/rollback_controller.py +586 -0
- chrys/app/tui/screens/main/runtime_info.py +71 -0
- chrys/app/tui/screens/main/screen.py +2265 -0
- chrys/app/tui/screens/main/screen.tcss +21 -0
- chrys/app/tui/screens/main/session_handlers.py +1127 -0
- chrys/app/tui/screens/main/session_title.py +239 -0
- chrys/app/tui/screens/main/settings_coordinator.py +365 -0
- chrys/app/tui/screens/main/settings_persistence.py +184 -0
- chrys/app/tui/screens/main/shell_mode.py +112 -0
- chrys/app/tui/screens/main/state.py +289 -0
- chrys/app/tui/screens/main/submit_state.py +11 -0
- chrys/app/tui/screens/main/subscriptions.py +177 -0
- chrys/app/tui/screens/main/suggestions.py +982 -0
- chrys/app/tui/screens/main/tool_action_bridge.py +75 -0
- chrys/app/tui/screens/main/view_adapter.py +1567 -0
- chrys/app/tui/screens/main/workflow_browser.py +747 -0
- chrys/app/tui/screens/main/workflow_content.py +248 -0
- chrys/app/tui/screens/main/workflow_controller.py +240 -0
- chrys/app/tui/screens/main/workflow_feedback.py +137 -0
- chrys/app/tui/screens/main/workflow_flow.py +49 -0
- chrys/app/tui/screens/main/workflow_run_control.py +456 -0
- chrys/app/tui/screens/main/workflow_session_view.py +316 -0
- chrys/app/tui/screens/main/workspace_actions.py +272 -0
- chrys/app/tui/screens/main/workspace_branch.py +194 -0
- chrys/app/tui/screens/menus/__init__.py +8 -0
- chrys/app/tui/screens/menus/languages.py +114 -0
- chrys/app/tui/screens/models/__init__.py +7 -0
- chrys/app/tui/screens/models/picker.py +147 -0
- chrys/app/tui/screens/models/screen.py +2043 -0
- chrys/app/tui/screens/models/screen.tcss +255 -0
- chrys/app/tui/screens/sessions/__init__.py +8 -0
- chrys/app/tui/screens/sessions/presenter.py +586 -0
- chrys/app/tui/screens/sessions/screen.py +779 -0
- chrys/app/tui/screens/sessions/screen.tcss +127 -0
- chrys/app/tui/screens/settings/__init__.py +29 -0
- chrys/app/tui/screens/settings/danger.py +26 -0
- chrys/app/tui/screens/settings/dialog.py +281 -0
- chrys/app/tui/screens/settings/layout.py +519 -0
- chrys/app/tui/screens/settings/migrate_dialog.py +253 -0
- chrys/app/tui/screens/settings/panes/__init__.py +3 -0
- chrys/app/tui/screens/settings/panes/notifications.py +248 -0
- chrys/app/tui/screens/settings/panes/sessions.py +148 -0
- chrys/app/tui/screens/settings/ports.py +55 -0
- chrys/app/tui/screens/settings/provenance.py +102 -0
- chrys/app/tui/screens/settings/rows.py +546 -0
- chrys/app/tui/screens/settings/settings.tcss +406 -0
- chrys/app/tui/screens/themes/__init__.py +8 -0
- chrys/app/tui/screens/themes/dialogs.py +330 -0
- chrys/app/tui/screens/themes/editor.py +632 -0
- chrys/app/tui/screens/themes/editor.tcss +268 -0
- chrys/app/tui/screens/themes/messages.py +95 -0
- chrys/app/tui/screens/themes/palette.py +694 -0
- chrys/app/tui/screens/themes/panel.py +113 -0
- chrys/app/tui/screens/themes/picker.py +203 -0
- chrys/app/tui/screens/themes/save.py +191 -0
- chrys/app/tui/support/__init__.py +3 -0
- chrys/app/tui/support/file_manager.py +64 -0
- chrys/app/tui/support/gc_freeze.py +1114 -0
- chrys/app/tui/support/path_shortcuts.py +50 -0
- chrys/app/tui/support/prompt_history.py +298 -0
- chrys/app/tui/support/workspace_mru.py +476 -0
- chrys/app/tui/terminal/__init__.py +6 -0
- chrys/app/tui/terminal/_pty_backend.py +114 -0
- chrys/app/tui/terminal/emulator/__init__.py +24 -0
- chrys/app/tui/terminal/emulator/buffer.py +456 -0
- chrys/app/tui/terminal/emulator/charsets.py +113 -0
- chrys/app/tui/terminal/emulator/core.py +963 -0
- chrys/app/tui/terminal/emulator/input.py +342 -0
- chrys/app/tui/terminal/emulator/parser.py +227 -0
- chrys/app/tui/terminal/emulator/pen.py +158 -0
- chrys/app/tui/terminal/launcher.py +455 -0
- chrys/app/tui/terminal/leaked_reports.py +44 -0
- chrys/app/tui/terminal/panel.py +323 -0
- chrys/app/tui/terminal/pty_process.py +405 -0
- chrys/app/tui/terminal/rendering.py +151 -0
- chrys/app/tui/terminal/shell.py +273 -0
- chrys/app/tui/terminal/shell_integration.py +199 -0
- chrys/app/tui/terminal/title.py +224 -0
- chrys/app/tui/terminal/widget.py +654 -0
- chrys/app/tui/theme.py +422 -0
- chrys/app/tui/theme_loader.py +201 -0
- chrys/app/tui/theme_stylesheet.py +49 -0
- chrys/app/tui/themes/__init__.py +3 -0
- chrys/app/tui/themes/document.py +120 -0
- chrys/app/tui/themes/preview.py +170 -0
- chrys/app/tui/themes/store.py +176 -0
- chrys/app/tui/toasts.py +69 -0
- chrys/app/tui/util/__init__.py +3 -0
- chrys/app/tui/util/context_pressure.py +146 -0
- chrys/app/tui/util/debug_log.py +292 -0
- chrys/app/tui/util/diff_entries.py +64 -0
- chrys/app/tui/util/formatting.py +53 -0
- chrys/app/tui/util/git_branch.py +326 -0
- chrys/app/tui/util/invocation_progress.py +57 -0
- chrys/app/tui/util/logo.py +32 -0
- chrys/app/tui/util/message_gate.py +35 -0
- chrys/app/tui/util/mutation_sources.py +14 -0
- chrys/app/tui/util/picker_text.py +27 -0
- chrys/app/tui/util/removal.py +47 -0
- chrys/app/tui/util/rich_style.py +36 -0
- chrys/app/tui/util/source_text.py +12 -0
- chrys/app/tui/util/static_update.py +88 -0
- chrys/app/tui/util/todo_format.py +51 -0
- chrys/app/tui/util/visibility.py +214 -0
- chrys/app/tui/widgets/__init__.py +60 -0
- chrys/app/tui/widgets/ask_user_controls.py +652 -0
- chrys/app/tui/widgets/ask_user_markdown.py +17 -0
- chrys/app/tui/widgets/ask_user_prompt.py +606 -0
- chrys/app/tui/widgets/buttons.py +52 -0
- chrys/app/tui/widgets/chat/__init__.py +3 -0
- chrys/app/tui/widgets/chat/agent_transcript_surface.py +818 -0
- chrys/app/tui/widgets/chat/chrome.py +296 -0
- chrys/app/tui/widgets/chat/compaction_card.py +353 -0
- chrys/app/tui/widgets/chat/context_fold.py +77 -0
- chrys/app/tui/widgets/chat/file_snapshot.py +91 -0
- chrys/app/tui/widgets/chat/image_preview.py +214 -0
- chrys/app/tui/widgets/chat/live_renderer.py +383 -0
- chrys/app/tui/widgets/chat/messages.py +1039 -0
- chrys/app/tui/widgets/chat/panel.py +1769 -0
- chrys/app/tui/widgets/chat/ports.py +245 -0
- chrys/app/tui/widgets/chat/renderers/__init__.py +7 -0
- chrys/app/tui/widgets/chat/renderers/ask_user.py +747 -0
- chrys/app/tui/widgets/chat/renderers/execute.py +431 -0
- chrys/app/tui/widgets/chat/renderers/file_edit.py +925 -0
- chrys/app/tui/widgets/chat/renderers/hosted_code.py +83 -0
- chrys/app/tui/widgets/chat/renderers/hosted_generic.py +330 -0
- chrys/app/tui/widgets/chat/renderers/hosted_image.py +107 -0
- chrys/app/tui/widgets/chat/renderers/hosted_mcp.py +43 -0
- chrys/app/tui/widgets/chat/renderers/hosted_search.py +167 -0
- chrys/app/tui/widgets/chat/renderers/hosted_shell.py +132 -0
- chrys/app/tui/widgets/chat/renderers/read_file.py +399 -0
- chrys/app/tui/widgets/chat/renderers/search.py +348 -0
- chrys/app/tui/widgets/chat/renderers/skill.py +564 -0
- chrys/app/tui/widgets/chat/renderers/sleep.py +287 -0
- chrys/app/tui/widgets/chat/renderers/sub_agent.py +1385 -0
- chrys/app/tui/widgets/chat/renderers/todo.py +114 -0
- chrys/app/tui/widgets/chat/renderers/web.py +69 -0
- chrys/app/tui/widgets/chat/replay.py +1605 -0
- chrys/app/tui/widgets/chat/replay_mount.py +322 -0
- chrys/app/tui/widgets/chat/scroll_controller.py +579 -0
- chrys/app/tui/widgets/chat/selection_controller.py +435 -0
- chrys/app/tui/widgets/chat/session_json.py +508 -0
- chrys/app/tui/widgets/chat/toc_model.py +156 -0
- chrys/app/tui/widgets/chat/tool_call.py +2478 -0
- chrys/app/tui/widgets/chat/tool_registry.py +753 -0
- chrys/app/tui/widgets/chat/tool_renderers.py +247 -0
- chrys/app/tui/widgets/chat/tool_view_builders.py +260 -0
- chrys/app/tui/widgets/chat/transcript_reader.py +57 -0
- chrys/app/tui/widgets/checkbox.py +24 -0
- chrys/app/tui/widgets/chrome/__init__.py +3 -0
- chrys/app/tui/widgets/chrome/app_header.py +283 -0
- chrys/app/tui/widgets/chrome/commands.py +95 -0
- chrys/app/tui/widgets/chrome/file_index.py +68 -0
- chrys/app/tui/widgets/chrome/file_scanner.py +670 -0
- chrys/app/tui/widgets/chrome/footer.py +284 -0
- chrys/app/tui/widgets/chrome/image_paste.py +351 -0
- chrys/app/tui/widgets/chrome/input_bar.py +1299 -0
- chrys/app/tui/widgets/chrome/status_bar.py +911 -0
- chrys/app/tui/widgets/chrome/suggestion_list.py +652 -0
- chrys/app/tui/widgets/click_affordance.py +24 -0
- chrys/app/tui/widgets/color_picker/__init__.py +254 -0
- chrys/app/tui/widgets/color_picker/controls.py +171 -0
- chrys/app/tui/widgets/color_picker/model.py +89 -0
- chrys/app/tui/widgets/dialog_buttons.py +58 -0
- chrys/app/tui/widgets/dialog_scroll.py +24 -0
- chrys/app/tui/widgets/diff_view/__init__.py +20 -0
- chrys/app/tui/widgets/diff_view/cells.py +59 -0
- chrys/app/tui/widgets/diff_view/code.py +280 -0
- chrys/app/tui/widgets/diff_view/compute.py +286 -0
- chrys/app/tui/widgets/diff_view/gutter.py +161 -0
- chrys/app/tui/widgets/diff_view/inline.py +77 -0
- chrys/app/tui/widgets/diff_view/palette.py +87 -0
- chrys/app/tui/widgets/diff_view/protocols.py +24 -0
- chrys/app/tui/widgets/diff_view/rows.py +138 -0
- chrys/app/tui/widgets/diff_view/unified.py +267 -0
- chrys/app/tui/widgets/diff_view/widget.py +209 -0
- chrys/app/tui/widgets/editor/__init__.py +79 -0
- chrys/app/tui/widgets/editor/coordinates.py +30 -0
- chrys/app/tui/widgets/editor/highlighter.py +139 -0
- chrys/app/tui/widgets/editor/keymaps.py +357 -0
- chrys/app/tui/widgets/editor/types.py +216 -0
- chrys/app/tui/widgets/editor/vim.py +1195 -0
- chrys/app/tui/widgets/editor/widget.py +401 -0
- chrys/app/tui/widgets/file_preview.py +58 -0
- chrys/app/tui/widgets/hatch.py +138 -0
- chrys/app/tui/widgets/input.py +66 -0
- chrys/app/tui/widgets/loading.py +125 -0
- chrys/app/tui/widgets/markdown/__init__.py +25 -0
- chrys/app/tui/widgets/markdown/_utils.py +21 -0
- chrys/app/tui/widgets/markdown/anchors.py +71 -0
- chrys/app/tui/widgets/markdown/blocks.py +115 -0
- chrys/app/tui/widgets/markdown/copyable.py +163 -0
- chrys/app/tui/widgets/markdown/diagram/__init__.py +35 -0
- chrys/app/tui/widgets/markdown/diagram/canvas.py +409 -0
- chrys/app/tui/widgets/markdown/diagram/charts.py +52 -0
- chrys/app/tui/widgets/markdown/diagram/diagnostics.py +101 -0
- chrys/app/tui/widgets/markdown/diagram/geometry.py +46 -0
- chrys/app/tui/widgets/markdown/diagram/layout.py +494 -0
- chrys/app/tui/widgets/markdown/diagram/messages.py +18 -0
- chrys/app/tui/widgets/markdown/diagram/model.py +275 -0
- chrys/app/tui/widgets/markdown/diagram/parser.py +173 -0
- chrys/app/tui/widgets/markdown/diagram/parsers/__init__.py +3 -0
- chrys/app/tui/widgets/markdown/diagram/parsers/charts.py +349 -0
- chrys/app/tui/widgets/markdown/diagram/parsers/common.py +403 -0
- chrys/app/tui/widgets/markdown/diagram/parsers/graphs.py +1072 -0
- chrys/app/tui/widgets/markdown/diagram/parsers/planning.py +317 -0
- chrys/app/tui/widgets/markdown/diagram/parsers/schedule.py +298 -0
- chrys/app/tui/widgets/markdown/diagram/parsers/schemas.py +241 -0
- chrys/app/tui/widgets/markdown/diagram/parsers/structure.py +221 -0
- chrys/app/tui/widgets/markdown/diagram/presentation.py +32 -0
- chrys/app/tui/widgets/markdown/diagram/renderers/__init__.py +3 -0
- chrys/app/tui/widgets/markdown/diagram/renderers/charts.py +275 -0
- chrys/app/tui/widgets/markdown/diagram/renderers/common.py +73 -0
- chrys/app/tui/widgets/markdown/diagram/renderers/planning.py +157 -0
- chrys/app/tui/widgets/markdown/diagram/renderers/schedule.py +202 -0
- chrys/app/tui/widgets/markdown/diagram/renderers/structure.py +77 -0
- chrys/app/tui/widgets/markdown/diagram/renderers/xy.py +385 -0
- chrys/app/tui/widgets/markdown/diagram/router.py +651 -0
- chrys/app/tui/widgets/markdown/diagram/specs/__init__.py +3 -0
- chrys/app/tui/widgets/markdown/diagram/specs/charts.py +94 -0
- chrys/app/tui/widgets/markdown/diagram/specs/planning.py +68 -0
- chrys/app/tui/widgets/markdown/diagram/specs/schedule.py +65 -0
- chrys/app/tui/widgets/markdown/diagram/specs/structure.py +23 -0
- chrys/app/tui/widgets/markdown/diagram/viewer.py +92 -0
- chrys/app/tui/widgets/markdown/links.py +47 -0
- chrys/app/tui/widgets/markdown/math/__init__.py +36 -0
- chrys/app/tui/widgets/markdown/math/layout.py +739 -0
- chrys/app/tui/widgets/markdown/math/markdown.py +291 -0
- chrys/app/tui/widgets/markdown/math/parser.py +325 -0
- chrys/app/tui/widgets/markdown/math/symbols.py +325 -0
- chrys/app/tui/widgets/markdown/navigator.py +80 -0
- chrys/app/tui/widgets/markdown/parser.py +1057 -0
- chrys/app/tui/widgets/markdown/stream.py +73 -0
- chrys/app/tui/widgets/markdown/toc.py +98 -0
- chrys/app/tui/widgets/markdown/viewer.py +146 -0
- chrys/app/tui/widgets/markdown/widget.py +2046 -0
- chrys/app/tui/widgets/marquee.py +101 -0
- chrys/app/tui/widgets/option_menu.py +82 -0
- chrys/app/tui/widgets/page_navigator.py +118 -0
- chrys/app/tui/widgets/select.py +196 -0
- chrys/app/tui/widgets/selection.py +59 -0
- chrys/app/tui/widgets/sidebar/__init__.py +3 -0
- chrys/app/tui/widgets/sidebar/buddy.py +367 -0
- chrys/app/tui/widgets/sidebar/context.py +482 -0
- chrys/app/tui/widgets/sidebar/debug.py +195 -0
- chrys/app/tui/widgets/sidebar/empty_state.py +11 -0
- chrys/app/tui/widgets/sidebar/panel.py +198 -0
- chrys/app/tui/widgets/sidebar/tasks.py +198 -0
- chrys/app/tui/widgets/sidebar/toc.py +166 -0
- chrys/app/tui/widgets/sidebar/toc_types.py +30 -0
- chrys/app/tui/widgets/syntax_theme.py +85 -0
- chrys/app/tui/widgets/text_area.py +258 -0
- chrys/app/tui/widgets/trajectory/__init__.py +8 -0
- chrys/app/tui/widgets/trajectory/chartkit.py +264 -0
- chrys/app/tui/widgets/trajectory/insights.py +1266 -0
- chrys/app/tui/widgets/trajectory/overview.py +1101 -0
- chrys/app/tui/widgets/trajectory/panel.py +983 -0
- chrys/app/tui/widgets/trajectory/presentation.py +311 -0
- chrys/app/tui/widgets/trajectory/session_info.py +124 -0
- chrys/app/tui/widgets/trajectory/text_view.py +124 -0
- chrys/app/tui/widgets/trajectory/timeline.py +380 -0
- chrys/app/tui/widgets/welcome.py +126 -0
- chrys/app/tui/widgets/workflow/__init__.py +5 -0
- chrys/app/tui/widgets/workflow/edges.py +89 -0
- chrys/app/tui/widgets/workflow/graph.py +869 -0
- chrys/app/tui/widgets/workflow/info.py +310 -0
- chrys/app/tui/widgets/workflow/node_view.py +27 -0
- chrys/app/tui/widgets/workflow/output.py +225 -0
- chrys/app/tui/widgets/workflow/panel.py +658 -0
- chrys/app/tui/widgets/workflow/projector.py +324 -0
- chrys/app/tui/widgets/workflow/records.py +69 -0
- chrys/app/tui/widgets/workflow/scrollbar.py +82 -0
- chrys/app/tui/widgets/workflow/selection.py +151 -0
- chrys/app/tui/widgets/workflow/source.py +65 -0
- chrys/app/tui/widgets/workflow/text.py +257 -0
- chrys/app/tui/widgets/workflow/values.py +317 -0
- chrys/foundation/__init__.py +0 -0
- chrys/foundation/branding.py +15 -0
- chrys/foundation/config/__init__.py +7 -0
- chrys/foundation/config/coercion.py +374 -0
- chrys/foundation/config/context.py +34 -0
- chrys/foundation/config/env_file.py +141 -0
- chrys/foundation/config/env_layers.py +370 -0
- chrys/foundation/config/migrations.py +521 -0
- chrys/foundation/config/notification_events.py +22 -0
- chrys/foundation/config/process_settings.py +332 -0
- chrys/foundation/config/runtime_pointer.py +133 -0
- chrys/foundation/config/settings.py +1952 -0
- chrys/foundation/config/settings_store.py +996 -0
- chrys/foundation/config/spec.py +396 -0
- chrys/foundation/config/user_settings.py +140 -0
- chrys/foundation/config/warnings.py +262 -0
- chrys/foundation/config/web_values.py +135 -0
- chrys/foundation/config/yaml_store.py +487 -0
- chrys/foundation/errors/__init__.py +68 -0
- chrys/foundation/errors/_legacy.py +117 -0
- chrys/foundation/errors/_text.py +13 -0
- chrys/foundation/errors/_walk.py +85 -0
- chrys/foundation/errors/classify.py +278 -0
- chrys/foundation/errors/display.py +274 -0
- chrys/foundation/errors/formatting.py +283 -0
- chrys/foundation/errors/kinds.py +67 -0
- chrys/foundation/errors/network.py +497 -0
- chrys/foundation/errors/provider.py +445 -0
- chrys/foundation/errors/route.py +85 -0
- chrys/foundation/events/__init__.py +8 -0
- chrys/foundation/events/bus.py +143 -0
- chrys/foundation/events/types.py +1629 -0
- chrys/foundation/events/workflow.py +38 -0
- chrys/foundation/hosted_tools.py +142 -0
- chrys/foundation/i18n/__init__.py +32 -0
- chrys/foundation/i18n/_catalogs/zh-Hans/LC_MESSAGES/chrys.mo +0 -0
- chrys/foundation/i18n/catalogs.py +42 -0
- chrys/foundation/i18n/formatting.py +202 -0
- chrys/foundation/i18n/locale.py +92 -0
- chrys/foundation/i18n/localizer.py +187 -0
- chrys/foundation/i18n/messages.py +268 -0
- chrys/foundation/io/__init__.py +3 -0
- chrys/foundation/io/result_content.py +237 -0
- chrys/foundation/models/__init__.py +3 -0
- chrys/foundation/models/approval_reuse.py +23 -0
- chrys/foundation/models/ask_user.py +468 -0
- chrys/foundation/models/execution.py +18 -0
- chrys/foundation/models/history_markers.py +115 -0
- chrys/foundation/models/invocations.py +63 -0
- chrys/foundation/models/mutation_scope.py +28 -0
- chrys/foundation/models/session_env.py +87 -0
- chrys/foundation/models/session_surface.py +29 -0
- chrys/foundation/models/todos.py +74 -0
- chrys/foundation/models/turns.py +125 -0
- chrys/foundation/models/workflow_session.py +89 -0
- chrys/foundation/models/workspace.py +59 -0
- chrys/foundation/net/__init__.py +3 -0
- chrys/foundation/net/address_class.py +57 -0
- chrys/foundation/net/route_probe.py +94 -0
- chrys/foundation/net/url.py +107 -0
- chrys/foundation/observability/__init__.py +3 -0
- chrys/foundation/observability/exporters.py +174 -0
- chrys/foundation/observability/gate.py +31 -0
- chrys/foundation/observability/setup.py +497 -0
- chrys/foundation/observability/sink.py +228 -0
- chrys/foundation/patches/__init__.py +19 -0
- chrys/foundation/patches/mdurl_cache.py +59 -0
- chrys/foundation/patches/patcher.py +287 -0
- chrys/foundation/patches/staged_members.py +205 -0
- chrys/foundation/patches/textual_block_border.py +105 -0
- chrys/foundation/patches/textual_button_ansi.py +65 -0
- chrys/foundation/patches/textual_button_ansi_old.txt +68 -0
- chrys/foundation/patches/textual_callback_cache.py +248 -0
- chrys/foundation/patches/textual_callback_dispatch.py +109 -0
- chrys/foundation/patches/textual_compositor_cjk.py +566 -0
- chrys/foundation/patches/textual_dispatch_cache.py +150 -0
- chrys/foundation/patches/textual_ime_cursor_anchor.py +92 -0
- chrys/foundation/patches/textual_kitty_keyboard.py +268 -0
- chrys/foundation/patches/textual_lru_acyclic.py +241 -0
- chrys/foundation/patches/textual_message_pump.py +136 -0
- chrys/foundation/patches/textual_node_diet.py +875 -0
- chrys/foundation/patches/textual_one_shot_timer.py +48 -0
- chrys/foundation/patches/textual_option_list.py +90 -0
- chrys/foundation/patches/textual_precompose.py +89 -0
- chrys/foundation/patches/textual_pruned_tabs.py +69 -0
- chrys/foundation/patches/textual_reflow_reuse.py +1753 -0
- chrys/foundation/patches/textual_removed_node_caches.py +86 -0
- chrys/foundation/patches/textual_removed_screen_callbacks.py +55 -0
- chrys/foundation/patches/textual_selection_extract.py +48 -0
- chrys/foundation/patches/textual_strip_cycles.py +119 -0
- chrys/foundation/patches/textual_tab_selection.py +1611 -0
- chrys/foundation/patches/textual_timer_skip.py +70 -0
- chrys/foundation/patches/textual_utf8_decoder.py +106 -0
- chrys/foundation/patches/textual_win_sleep.py +167 -0
- chrys/foundation/patches/watchdog_fsevents.py +70 -0
- chrys/foundation/patches/watchdog_windows.py +52 -0
- chrys/foundation/platform/__init__.py +556 -0
- chrys/foundation/platform/c_api.py +32 -0
- chrys/foundation/platform/child_reap.py +107 -0
- chrys/foundation/platform/command_line.py +95 -0
- chrys/foundation/platform/files.py +1470 -0
- chrys/foundation/platform/output_capture.py +113 -0
- chrys/foundation/platform/paths.py +193 -0
- chrys/foundation/platform/process.py +1468 -0
- chrys/foundation/platform/pty_output.py +19 -0
- chrys/foundation/platform/runtime_paths.py +281 -0
- chrys/foundation/platform/win_console.py +129 -0
- chrys/foundation/reasoning_origin.py +67 -0
- chrys/foundation/recovery.py +16 -0
- chrys/foundation/retry.py +278 -0
- chrys/foundation/text/__init__.py +3 -0
- chrys/foundation/text/encoding.py +603 -0
- chrys/foundation/text/images.py +304 -0
- chrys/foundation/text/lines.py +24 -0
- chrys/foundation/text/mentions.py +90 -0
- chrys/foundation/text/model_json.py +25 -0
- chrys/foundation/text/reading_order.py +20 -0
- chrys/foundation/text/tokenizer.py +25 -0
- chrys/foundation/text/tool_output.py +287 -0
- chrys/foundation/text/yaml_io.py +62 -0
- chrys/foundation/tool_call_context.py +202 -0
- chrys/foundation/tool_execution_stamp.py +184 -0
- chrys/foundation/tool_invocation_order.py +48 -0
- chrys/foundation/tool_kinds.py +102 -0
- chrys/foundation/tool_result_metadata.py +287 -0
- chrys/foundation/trajectory/__init__.py +10 -0
- chrys/foundation/trajectory/context.py +481 -0
- chrys/foundation/trajectory/envelope.py +571 -0
- chrys/foundation/trajectory/event_types.py +294 -0
- chrys/foundation/trajectory/finalizers.py +46 -0
- chrys/foundation/trajectory/fingerprint.py +62 -0
- chrys/foundation/trajectory/ids.py +155 -0
- chrys/foundation/trajectory/keys.py +99 -0
- chrys/foundation/trajectory/lease.py +173 -0
- chrys/foundation/trajectory/metadata.py +72 -0
- chrys/foundation/trajectory/reader.py +271 -0
- chrys/foundation/trajectory/recovery.py +225 -0
- chrys/foundation/trajectory/revisions.py +152 -0
- chrys/foundation/trajectory/segments.py +225 -0
- chrys/foundation/trajectory/tools.py +117 -0
- chrys/foundation/trajectory/usage.py +151 -0
- chrys/foundation/trajectory/writer.py +972 -0
- chrys/foundation/trajectory_timing.py +97 -0
- chrys/foundation/util/__init__.py +3 -0
- chrys/foundation/util/async_tasks.py +29 -0
- chrys/foundation/util/chrys_headers.py +23 -0
- chrys/foundation/util/env_templates.py +104 -0
- chrys/foundation/util/filenames.py +96 -0
- chrys/foundation/util/header_charset.py +138 -0
- chrys/foundation/util/httpx_helpers.py +12 -0
- chrys/foundation/util/lock.py +236 -0
- chrys/foundation/util/once_close.py +46 -0
- chrys/foundation/util/session_ids.py +29 -0
- chrys/foundation/util/shell_tokens.py +88 -0
- chrys/foundation/util/sub_agent_context.py +38 -0
- chrys/foundation/util/time.py +35 -0
- chrys/foundation/util/unicode_scalars.py +36 -0
- chrys/foundation/vendor/__init__.py +43 -0
- chrys/foundation/vendor/ripgrep/rg-aarch64-apple-darwin +0 -0
- chrys/foundation/vendor/ripgrep/rg-aarch64-pc-windows-msvc.exe +0 -0
- chrys/foundation/vendor/ripgrep/rg-aarch64-unknown-linux-gnu +0 -0
- chrys/foundation/vendor/ripgrep/rg-x86_64-apple-darwin +0 -0
- chrys/foundation/vendor/ripgrep/rg-x86_64-pc-windows-msvc.exe +0 -0
- chrys/foundation/vendor/ripgrep/rg-x86_64-unknown-linux-musl +0 -0
- chrys/kernel/__init__.py +256 -0
- chrys/kernel/_content.py +1866 -0
- chrys/kernel/_loop_recorder.py +631 -0
- chrys/kernel/_null_overlay.py +1026 -0
- chrys/kernel/_result_ceiling.py +216 -0
- chrys/kernel/_serialization.py +431 -0
- chrys/kernel/_tool_arg_errors.py +1629 -0
- chrys/kernel/_tool_execution.py +1172 -0
- chrys/kernel/_tool_expansion.py +26 -0
- chrys/kernel/_types.py +2598 -0
- chrys/kernel/agent.py +994 -0
- chrys/kernel/client.py +1084 -0
- chrys/kernel/compaction.py +1315 -0
- chrys/kernel/exceptions.py +211 -0
- chrys/kernel/exchanges.py +683 -0
- chrys/kernel/identity.py +95 -0
- chrys/kernel/images.py +36 -0
- chrys/kernel/instrumentation.py +1618 -0
- chrys/kernel/loop.py +2308 -0
- chrys/kernel/middleware.py +616 -0
- chrys/kernel/sessions.py +604 -0
- chrys/kernel/tools.py +1061 -0
- chrys/kernel/types.py +126 -0
- chrys/orchestration/__init__.py +3 -0
- chrys/orchestration/engine/__init__.py +3 -0
- chrys/orchestration/engine/assembly.py +236 -0
- chrys/orchestration/engine/build/__init__.py +3 -0
- chrys/orchestration/engine/build/builder.py +1061 -0
- chrys/orchestration/engine/build/construction.py +408 -0
- chrys/orchestration/engine/build/loaded.py +139 -0
- chrys/orchestration/engine/engine.py +789 -0
- chrys/orchestration/engine/execution.py +771 -0
- chrys/orchestration/engine/loader.py +865 -0
- chrys/orchestration/engine/rollback.py +918 -0
- chrys/orchestration/engine/run/__init__.py +3 -0
- chrys/orchestration/engine/run/active_injection.py +419 -0
- chrys/orchestration/engine/run/attachments.py +519 -0
- chrys/orchestration/engine/run/bindings.py +1095 -0
- chrys/orchestration/engine/run/coordinator.py +1062 -0
- chrys/orchestration/engine/run/finalizer.py +417 -0
- chrys/orchestration/engine/run/input_refs.py +60 -0
- chrys/orchestration/engine/run/prompt_content.py +457 -0
- chrys/orchestration/engine/run/resume.py +299 -0
- chrys/orchestration/engine/run/retry.py +948 -0
- chrys/orchestration/engine/run/runner.py +625 -0
- chrys/orchestration/engine/run/runtime_skills.py +146 -0
- chrys/orchestration/engine/run/sub_agent_coordination.py +126 -0
- chrys/orchestration/engine/run/turn_hooks.py +258 -0
- chrys/orchestration/engine/run/turn_state.py +149 -0
- chrys/orchestration/engine/run/working_dir.py +50 -0
- chrys/orchestration/engine/session_lifecycle.py +2013 -0
- chrys/orchestration/engine/state/__init__.py +3 -0
- chrys/orchestration/engine/state/active_session.py +183 -0
- chrys/orchestration/engine/state/controls.py +639 -0
- chrys/orchestration/engine/state/current_agent.py +26 -0
- chrys/orchestration/engine/state/lifecycle_permits.py +395 -0
- chrys/orchestration/engine/state/machine.py +193 -0
- chrys/orchestration/engine/state/session_writer.py +434 -0
- chrys/orchestration/engine/trajectory.py +714 -0
- chrys/orchestration/engine/usage.py +118 -0
- chrys/orchestration/invoker/__init__.py +3 -0
- chrys/orchestration/invoker/acp.py +727 -0
- chrys/orchestration/invoker/acp_protocol.py +1115 -0
- chrys/orchestration/invoker/acp_spec.py +79 -0
- chrys/orchestration/invoker/attempts.py +994 -0
- chrys/orchestration/invoker/child_compaction.py +131 -0
- chrys/orchestration/invoker/child_history.py +99 -0
- chrys/orchestration/invoker/contracts.py +253 -0
- chrys/orchestration/invoker/evidence.py +94 -0
- chrys/orchestration/invoker/kernel.py +343 -0
- chrys/orchestration/invoker/origin.py +79 -0
- chrys/orchestration/invoker/resources.py +343 -0
- chrys/orchestration/invoker/runtime.py +299 -0
- chrys/orchestration/invoker/usage.py +80 -0
- chrys/orchestration/session_hooks.py +117 -0
- chrys/orchestration/session_host.py +1023 -0
- chrys/orchestration/session_resources.py +95 -0
- chrys/orchestration/session_usage.py +219 -0
- chrys/orchestration/startup.py +255 -0
- chrys/orchestration/sub_agents/__init__.py +3 -0
- chrys/orchestration/sub_agents/acp_policy.py +269 -0
- chrys/orchestration/sub_agents/kernel_policy.py +848 -0
- chrys/orchestration/sub_agents/registration.py +44 -0
- chrys/orchestration/sub_agents/shell.py +337 -0
- chrys/orchestration/sub_agents/tools.py +2228 -0
- chrys/orchestration/workflows/__init__.py +3 -0
- chrys/orchestration/workflows/agent_archive.py +156 -0
- chrys/orchestration/workflows/agent_node.py +894 -0
- chrys/orchestration/workflows/agent_node_build.py +687 -0
- chrys/orchestration/workflows/catalog.py +332 -0
- chrys/orchestration/workflows/coordinator.py +771 -0
- chrys/orchestration/workflows/hooks.py +176 -0
- chrys/orchestration/workflows/preview.py +322 -0
- chrys/orchestration/workflows/rollback.py +151 -0
- chrys/orchestration/workflows/runner.py +986 -0
- chrys/orchestration/workflows/session.py +390 -0
- chrys/orchestration/workflows/settings.py +97 -0
- chrys/orchestration/workflows/validation.py +674 -0
- chrys/orchestration/workflows/worker_client.py +1002 -0
- chrys/service/__init__.py +3 -0
- chrys/service/acp_client/__init__.py +65 -0
- chrys/service/acp_client/_transport.py +922 -0
- chrys/service/acp_client/client.py +1311 -0
- chrys/service/acp_client/errors.py +224 -0
- chrys/service/acp_client/protocol.py +220 -0
- chrys/service/acp_client/spawn.py +239 -0
- chrys/service/acp_client/spec.py +100 -0
- chrys/service/agent_middleware/__init__.py +60 -0
- chrys/service/agent_middleware/_metadata_keys.py +52 -0
- chrys/service/agent_middleware/control/__init__.py +3 -0
- chrys/service/agent_middleware/control/approval.py +769 -0
- chrys/service/agent_middleware/control/approval_hooks.py +62 -0
- chrys/service/agent_middleware/control/ask_user.py +207 -0
- chrys/service/agent_middleware/control/interrupt.py +50 -0
- chrys/service/agent_middleware/control/sleep.py +118 -0
- chrys/service/agent_middleware/control/todo.py +117 -0
- chrys/service/agent_middleware/events/__init__.py +3 -0
- chrys/service/agent_middleware/events/hook_dispatch.py +250 -0
- chrys/service/agent_middleware/events/hosted_tools.py +1762 -0
- chrys/service/agent_middleware/events/intermediate_text.py +128 -0
- chrys/service/agent_middleware/events/rejection_metadata.py +50 -0
- chrys/service/agent_middleware/events/result_persistence.py +98 -0
- chrys/service/agent_middleware/events/sub_agent_events.py +951 -0
- chrys/service/agent_middleware/events/tool_error_text.py +22 -0
- chrys/service/agent_middleware/events/tool_events.py +779 -0
- chrys/service/agent_middleware/injection.py +510 -0
- chrys/service/agent_middleware/reminders/__init__.py +91 -0
- chrys/service/agent_middleware/reminders/archive_pointer.py +154 -0
- chrys/service/agent_middleware/reminders/base.py +25 -0
- chrys/service/agent_middleware/reminders/context_usage.py +74 -0
- chrys/service/agent_middleware/reminders/file_change.py +63 -0
- chrys/service/agent_middleware/reminders/mcp.py +39 -0
- chrys/service/agent_middleware/reminders/profile_switch.py +106 -0
- chrys/service/agent_middleware/reminders/runtime_env.py +190 -0
- chrys/service/agent_middleware/reminders/skills.py +44 -0
- chrys/service/agent_middleware/reminders/sub_agents.py +35 -0
- chrys/service/agent_middleware/reminders/todo.py +35 -0
- chrys/service/agent_middleware/reminders/turn_line.py +48 -0
- chrys/service/agent_middleware/response_validation.py +1380 -0
- chrys/service/agent_middleware/system_reminder.py +1107 -0
- chrys/service/agent_middleware/validators.py +396 -0
- chrys/service/analytics/__init__.py +124 -0
- chrys/service/analytics/_actions.py +896 -0
- chrys/service/analytics/_context_evidence.py +357 -0
- chrys/service/analytics/_critical_path.py +179 -0
- chrys/service/analytics/_facts.py +919 -0
- chrys/service/analytics/_insights.py +769 -0
- chrys/service/analytics/_metric_ops.py +83 -0
- chrys/service/analytics/_session_projection.py +460 -0
- chrys/service/analytics/_timeline.py +434 -0
- chrys/service/analytics/_turn_graph.py +1237 -0
- chrys/service/analytics/_turns.py +1377 -0
- chrys/service/analytics/_workflows.py +171 -0
- chrys/service/analytics/aggregation.py +1474 -0
- chrys/service/analytics/classification.py +128 -0
- chrys/service/analytics/export.py +803 -0
- chrys/service/analytics/findings.py +282 -0
- chrys/service/analytics/math.py +79 -0
- chrys/service/analytics/model.py +886 -0
- chrys/service/analytics/reader.py +321 -0
- chrys/service/approval/__init__.py +3 -0
- chrys/service/approval/arbitration.py +102 -0
- chrys/service/approval/command_identity.py +109 -0
- chrys/service/approval/correlation.py +57 -0
- chrys/service/approval/grant_store.py +108 -0
- chrys/service/approval/judge.py +589 -0
- chrys/service/approval/level.py +15 -0
- chrys/service/approval/policy.py +209 -0
- chrys/service/approval/reuse.py +223 -0
- chrys/service/approval/reuse_binding.py +133 -0
- chrys/service/approval/safety_classifier.py +536 -0
- chrys/service/approval/turn_context.py +49 -0
- chrys/service/context/__init__.py +3 -0
- chrys/service/context/compaction/__init__.py +38 -0
- chrys/service/context/compaction/breaker.py +156 -0
- chrys/service/context/compaction/budgets.py +93 -0
- chrys/service/context/compaction/calibration.py +77 -0
- chrys/service/context/compaction/compression.py +690 -0
- chrys/service/context/compaction/current_turn_drop.py +458 -0
- chrys/service/context/compaction/events.py +201 -0
- chrys/service/context/compaction/exclusions.py +261 -0
- chrys/service/context/compaction/groups.py +361 -0
- chrys/service/context/compaction/last_words.py +1996 -0
- chrys/service/context/compaction/last_words_state.py +752 -0
- chrys/service/context/compaction/scoped.py +472 -0
- chrys/service/context/compaction/spill.py +1947 -0
- chrys/service/context/compaction/strategy.py +1116 -0
- chrys/service/context/compaction/summaries.py +319 -0
- chrys/service/context/compaction/turns.py +375 -0
- chrys/service/context/manager.py +174 -0
- chrys/service/context/memory_loader.py +792 -0
- chrys/service/context/middleware/__init__.py +3 -0
- chrys/service/context/middleware/usage.py +332 -0
- chrys/service/context/providers/__init__.py +3 -0
- chrys/service/context/providers/context_management.py +432 -0
- chrys/service/context/providers/history.py +647 -0
- chrys/service/context/providers/memory.py +50 -0
- chrys/service/context/tool_content.py +22 -0
- chrys/service/hooks/__init__.py +37 -0
- chrys/service/hooks/detached_worker.py +118 -0
- chrys/service/hooks/docs/README.md +14 -0
- chrys/service/hooks/docs/authoring.md +684 -0
- chrys/service/hooks/docs/configuration.md +499 -0
- chrys/service/hooks/docs/design.md +359 -0
- chrys/service/hooks/docs/project-hooks.md +348 -0
- chrys/service/hooks/events.py +157 -0
- chrys/service/hooks/loader.py +665 -0
- chrys/service/hooks/manager.py +807 -0
- chrys/service/hooks/matcher.py +111 -0
- chrys/service/hooks/outbox.py +294 -0
- chrys/service/hooks/runner.py +572 -0
- chrys/service/hooks/schema.py +407 -0
- chrys/service/llm/__init__.py +7 -0
- chrys/service/llm/_structured_outputs.py +192 -0
- chrys/service/llm/anthropic_messages/__init__.py +9 -0
- chrys/service/llm/anthropic_messages/client.py +254 -0
- chrys/service/llm/anthropic_messages/decode.py +331 -0
- chrys/service/llm/anthropic_messages/history.py +279 -0
- chrys/service/llm/anthropic_messages/request.py +323 -0
- chrys/service/llm/anthropic_messages/server_tools.py +319 -0
- chrys/service/llm/anthropic_messages/stream.py +297 -0
- chrys/service/llm/anthropic_messages/thinking_binding.py +156 -0
- chrys/service/llm/chat_completions/__init__.py +9 -0
- chrys/service/llm/chat_completions/client.py +369 -0
- chrys/service/llm/chat_completions/decode.py +338 -0
- chrys/service/llm/chat_completions/history.py +657 -0
- chrys/service/llm/chat_completions/reasoning.py +244 -0
- chrys/service/llm/chat_completions/request.py +192 -0
- chrys/service/llm/chat_completions/stream.py +546 -0
- chrys/service/llm/chat_completions/validation.py +308 -0
- chrys/service/llm/clients.py +613 -0
- chrys/service/llm/images.py +65 -0
- chrys/service/llm/mock.py +413 -0
- chrys/service/llm/observer.py +446 -0
- chrys/service/llm/one_shot.py +106 -0
- chrys/service/llm/openai_exceptions.py +93 -0
- chrys/service/llm/openai_responses/__init__.py +10 -0
- chrys/service/llm/openai_responses/client.py +323 -0
- chrys/service/llm/openai_responses/decode.py +432 -0
- chrys/service/llm/openai_responses/history.py +519 -0
- chrys/service/llm/openai_responses/hosted.py +625 -0
- chrys/service/llm/openai_responses/replay.py +629 -0
- chrys/service/llm/openai_responses/request.py +342 -0
- chrys/service/llm/openai_responses/stream.py +847 -0
- chrys/service/llm/openai_timestamps.py +57 -0
- chrys/service/llm/providers.py +107 -0
- chrys/service/llm/proxy_route.py +54 -0
- chrys/service/llm/raw_http_log.py +309 -0
- chrys/service/llm/route_facts.py +63 -0
- chrys/service/llm/route_sessions.py +49 -0
- chrys/service/llm/sdk_anthropic.py +18 -0
- chrys/service/llm/sdk_headers.py +47 -0
- chrys/service/llm/sdk_openai.py +18 -0
- chrys/service/llm/sdk_retry.py +59 -0
- chrys/service/llm/wire_client.py +246 -0
- chrys/service/mcp/__init__.py +3 -0
- chrys/service/mcp/_connection.py +235 -0
- chrys/service/mcp/_http_transport.py +350 -0
- chrys/service/mcp/_progressive.py +459 -0
- chrys/service/mcp/_stdio_transport.py +614 -0
- chrys/service/mcp/_tool_mixins.py +96 -0
- chrys/service/mcp/adapter.py +1031 -0
- chrys/service/mcp/cache.py +476 -0
- chrys/service/mcp/content.py +32 -0
- chrys/service/mcp/errors.py +169 -0
- chrys/service/mcp/owned.py +1330 -0
- chrys/service/mcp/result_limits.py +168 -0
- chrys/service/mcp/thinking_warning.py +95 -0
- chrys/service/mcp/validation.py +74 -0
- chrys/service/mutations/__init__.py +84 -0
- chrys/service/mutations/coordination.py +894 -0
- chrys/service/mutations/detector.py +458 -0
- chrys/service/mutations/git_calibrator.py +397 -0
- chrys/service/mutations/git_state.py +647 -0
- chrys/service/mutations/pipeline.py +779 -0
- chrys/service/mutations/scanner.py +466 -0
- chrys/service/mutations/snapshot_files.py +233 -0
- chrys/service/mutations/store.py +625 -0
- chrys/service/mutations/tool_names.py +15 -0
- chrys/service/mutations/trace.py +246 -0
- chrys/service/mutations/tracker.py +1289 -0
- chrys/service/mutations/types.py +559 -0
- chrys/service/mutations/workspace_changes.py +1583 -0
- chrys/service/profiles/__init__.py +2 -0
- chrys/service/profiles/agents/__init__.py +3 -0
- chrys/service/profiles/agents/builtins/Code.yaml +199 -0
- chrys/service/profiles/agents/builtins/Explore.yaml +62 -0
- chrys/service/profiles/agents/builtins/General.yaml +114 -0
- chrys/service/profiles/agents/builtins/QA.yaml +128 -0
- chrys/service/profiles/agents/loader.py +808 -0
- chrys/service/profiles/agents/registry.py +727 -0
- chrys/service/profiles/agents/schema.py +371 -0
- chrys/service/profiles/agents/serializer.py +196 -0
- chrys/service/profiles/coercion.py +22 -0
- chrys/service/profiles/models/__init__.py +3 -0
- chrys/service/profiles/models/env_bridge.py +157 -0
- chrys/service/profiles/models/loader.py +282 -0
- chrys/service/profiles/models/options.py +438 -0
- chrys/service/profiles/models/registry.py +104 -0
- chrys/service/profiles/models/resolver.py +286 -0
- chrys/service/profiles/models/schema.py +91 -0
- chrys/service/profiles/models/serializer.py +76 -0
- chrys/service/session/__init__.py +3 -0
- chrys/service/session/checkpoint.py +156 -0
- chrys/service/session/history.py +1616 -0
- chrys/service/session/message_metadata.py +151 -0
- chrys/service/session/persistence.py +435 -0
- chrys/service/session/runtime_metadata.py +210 -0
- chrys/service/session/sub_agent_logs.py +1069 -0
- chrys/service/session/sub_agent_transcript.py +654 -0
- chrys/service/skills/__init__.py +3 -0
- chrys/service/skills/adapter.py +408 -0
- chrys/service/skills/constants.py +8 -0
- chrys/service/skills/loader.py +575 -0
- chrys/service/skills/model.py +130 -0
- chrys/service/skills/provider.py +841 -0
- chrys/service/skills/runner.py +390 -0
- chrys/service/state/__init__.py +3 -0
- chrys/service/state/_fork.py +710 -0
- chrys/service/state/_session_files.py +175 -0
- chrys/service/state/_session_meta.py +552 -0
- chrys/service/state/locks.py +154 -0
- chrys/service/state/serializers.py +156 -0
- chrys/service/state/session_catalog.py +361 -0
- chrys/service/state/session_listing.py +84 -0
- chrys/service/state/session_migration.py +446 -0
- chrys/service/state/session_mru.py +315 -0
- chrys/service/state/store.py +2132 -0
- chrys/service/state/workflow.py +158 -0
- chrys/service/text_blocks.py +47 -0
- chrys/service/todos/__init__.py +3 -0
- chrys/service/todos/reminder.py +31 -0
- chrys/service/todos/tracker.py +58 -0
- chrys/service/tools/__init__.py +3 -0
- chrys/service/tools/approval_targets.py +92 -0
- chrys/service/tools/builtins/__init__.py +3 -0
- chrys/service/tools/builtins/ask_user.py +326 -0
- chrys/service/tools/builtins/doc_converter/__init__.py +494 -0
- chrys/service/tools/builtins/doc_converter/artifacts.py +392 -0
- chrys/service/tools/builtins/doc_converter/parsers/__init__.py +3 -0
- chrys/service/tools/builtins/doc_converter/parsers/_table.py +34 -0
- chrys/service/tools/builtins/doc_converter/parsers/base.py +101 -0
- chrys/service/tools/builtins/doc_converter/parsers/docx.py +88 -0
- chrys/service/tools/builtins/doc_converter/parsers/pdf.py +144 -0
- chrys/service/tools/builtins/doc_converter/parsers/pptx.py +103 -0
- chrys/service/tools/builtins/doc_converter/parsers/xls.py +32 -0
- chrys/service/tools/builtins/doc_converter/parsers/xlsx.py +51 -0
- chrys/service/tools/builtins/doc_converter/registry.py +55 -0
- chrys/service/tools/builtins/filesystem.py +1071 -0
- chrys/service/tools/builtins/search.py +1265 -0
- chrys/service/tools/builtins/shell.py +967 -0
- chrys/service/tools/builtins/shell_command_filtering/__init__.py +5 -0
- chrys/service/tools/builtins/shell_command_filtering/policy.py +60 -0
- chrys/service/tools/builtins/shell_command_filtering/presets.py +152 -0
- chrys/service/tools/builtins/shell_command_filtering/readonly/__init__.py +358 -0
- chrys/service/tools/builtins/shell_command_filtering/readonly/commands.py +416 -0
- chrys/service/tools/builtins/shell_command_filtering/readonly/git.py +408 -0
- chrys/service/tools/builtins/shell_command_filtering/readonly/syntax.py +87 -0
- chrys/service/tools/builtins/shell_command_filtering/tokenizer.py +214 -0
- chrys/service/tools/builtins/shell_filter.py +24 -0
- chrys/service/tools/builtins/sleep.py +91 -0
- chrys/service/tools/builtins/todo.py +64 -0
- chrys/service/tools/builtins/web/__init__.py +6 -0
- chrys/service/tools/builtins/web/build.py +268 -0
- chrys/service/tools/builtins/web/config.py +338 -0
- chrys/service/tools/builtins/web/credentials.py +37 -0
- chrys/service/tools/builtins/web/fetch/__init__.py +3 -0
- chrys/service/tools/builtins/web/fetch/cache.py +68 -0
- chrys/service/tools/builtins/web/fetch/convert.py +56 -0
- chrys/service/tools/builtins/web/fetch/tool.py +223 -0
- chrys/service/tools/builtins/web/hosted.py +61 -0
- chrys/service/tools/builtins/web/http.py +330 -0
- chrys/service/tools/builtins/web/progress.py +18 -0
- chrys/service/tools/builtins/web/search/__init__.py +3 -0
- chrys/service/tools/builtins/web/search/exa_mcp.py +146 -0
- chrys/service/tools/builtins/web/search/html.py +152 -0
- chrys/service/tools/builtins/web/search/json_api.py +227 -0
- chrys/service/tools/builtins/web/search/providers.py +77 -0
- chrys/service/tools/builtins/web/search/tool.py +218 -0
- chrys/service/tools/builtins/web/search/types.py +28 -0
- chrys/service/tools/builtins/web/tls_identity.py +66 -0
- chrys/service/tools/kinds.py +115 -0
- chrys/service/tools/names.py +47 -0
- chrys/service/tools/registry.py +242 -0
- chrys/service/tools/result_metadata.py +103 -0
- chrys/service/tools/session_artifacts.py +196 -0
- chrys/service/tools/spill.py +218 -0
- chrys/service/tools/workspace_paths.py +81 -0
- chrys/service/trajectory/__init__.py +11 -0
- chrys/service/trajectory/approvals.py +201 -0
- chrys/service/trajectory/compaction.py +420 -0
- chrys/service/trajectory/fork.py +75 -0
- chrys/service/trajectory/hooks.py +217 -0
- chrys/service/trajectory/items.py +41 -0
- chrys/service/trajectory/preparation.py +374 -0
- chrys/service/trajectory/retries.py +95 -0
- chrys/service/trajectory/revisions.py +129 -0
- chrys/service/trajectory/segmented.py +163 -0
- chrys/service/trajectory/session.py +762 -0
- chrys/service/trajectory/state.py +83 -0
- chrys/service/trajectory/sub_agents.py +169 -0
- chrys/service/trajectory/tombstone.py +262 -0
- chrys/service/trajectory/tools.py +323 -0
- chrys/service/trajectory/validation.py +130 -0
- chrys/service/trajectory/waits.py +161 -0
- chrys/service/trajectory/workflow.py +148 -0
- chrys/service/vision.py +128 -0
- chrys/service/workflows/__init__.py +3 -0
- chrys/service/workflows/admission.py +151 -0
- chrys/service/workflows/artifacts.py +109 -0
- chrys/service/workflows/asks.py +130 -0
- chrys/service/workflows/builtins/__init__.py +12 -0
- chrys/service/workflows/builtins/demo-workflow.manifest.json +635 -0
- chrys/service/workflows/builtins/demo-workflow.py +756 -0
- chrys/service/workflows/discovery.py +554 -0
- chrys/service/workflows/environment.py +284 -0
- chrys/service/workflows/graph.py +430 -0
- chrys/service/workflows/history.py +82 -0
- chrys/service/workflows/interpreter.py +107 -0
- chrys/service/workflows/journal.py +334 -0
- chrys/service/workflows/layout.py +56 -0
- chrys/service/workflows/ledger.py +149 -0
- chrys/service/workflows/model_selection.py +18 -0
- chrys/service/workflows/orphans.py +209 -0
- chrys/service/workflows/outcomes.py +46 -0
- chrys/service/workflows/protocol.py +210 -0
- chrys/service/workflows/records.py +142 -0
- chrys/service/workflows/scheduler.py +770 -0
- chrys/service/workflows/sdk/__init__.py +51 -0
- chrys/service/workflows/sdk/_ask.py +167 -0
- chrys/service/workflows/sdk/_builder.py +781 -0
- chrys/service/workflows/sdk/_values.py +92 -0
- chrys/service/workflows/sdk_artifact.py +74 -0
- chrys/service/workflows/store.py +494 -0
- chrys/service/workflows/transcript.py +102 -0
- chrys/service/workflows/values.py +73 -0
- chrys/service/workflows/worker_host.py +1820 -0
- chrys/workflows.py +38 -0
- icode_tui-0.29.1.dist-info/METADATA +104 -0
- icode_tui-0.29.1.dist-info/RECORD +1138 -0
- icode_tui-0.29.1.dist-info/WHEEL +4 -0
- icode_tui-0.29.1.dist-info/entry_points.txt +2 -0
- icode_tui-0.29.1.dist-info/licenses/LICENSE +202 -0
- icode_tui-0.29.1.dist-info/licenses/NOTICE +1955 -0
|
@@ -0,0 +1,2046 @@
|
|
|
1
|
+
# Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved.
|
|
2
|
+
|
|
3
|
+
"""VirtualizedMarkdown — high-performance markdown widget using the Line API.
|
|
4
|
+
|
|
5
|
+
Uses virtualization (only renders visible lines) for optimal performance
|
|
6
|
+
with large documents. Drop-in replacement for Textual's built-in Markdown
|
|
7
|
+
widget, but avoids mounting one widget per block.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
from bisect import bisect_left, bisect_right
|
|
14
|
+
from collections import OrderedDict
|
|
15
|
+
from collections.abc import Callable, Coroutine, Sequence
|
|
16
|
+
from dataclasses import dataclass, replace
|
|
17
|
+
from functools import partial
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any, ClassVar
|
|
20
|
+
from urllib.parse import unquote
|
|
21
|
+
|
|
22
|
+
from markdown_it import MarkdownIt
|
|
23
|
+
from rich.cells import set_cell_size
|
|
24
|
+
from rich.segment import Segment
|
|
25
|
+
from rich.style import Style as RichStyle
|
|
26
|
+
from textual._cells import cell_len
|
|
27
|
+
from textual.await_complete import AwaitComplete
|
|
28
|
+
from textual.cache import LRUCache
|
|
29
|
+
from textual.content import Content, Span
|
|
30
|
+
from textual.dom import NoScreen
|
|
31
|
+
from textual.events import Click, Leave, Mount, MouseMove, Resize
|
|
32
|
+
from textual.geometry import Offset, Region, Size
|
|
33
|
+
from textual.message import Message
|
|
34
|
+
from textual.scroll_view import ScrollView
|
|
35
|
+
from textual.selection import Selection
|
|
36
|
+
from textual.strip import Strip
|
|
37
|
+
from textual.style import Style
|
|
38
|
+
from textual.visual import RenderOptions
|
|
39
|
+
from textual.widget import Widget
|
|
40
|
+
|
|
41
|
+
from chrys.app.tui.i18n import render_str, widget_localizer
|
|
42
|
+
from chrys.app.tui.support.gc_freeze import detach_lru_cache, renew_lru_cache
|
|
43
|
+
from chrys.app.tui.widgets import normalize_selection_rich_style as _normalize_selection_rich_style
|
|
44
|
+
from chrys.app.tui.widgets.markdown._utils import sanitize_location as _sanitize_location
|
|
45
|
+
from chrys.app.tui.widgets.markdown.anchors import find_heading
|
|
46
|
+
from chrys.app.tui.widgets.markdown.blocks import MarkdownBlock, MarkdownGutter, TableOfContentsType, _BlockLineInfo
|
|
47
|
+
from chrys.app.tui.widgets.markdown.diagram import compile_mermaid
|
|
48
|
+
from chrys.app.tui.widgets.markdown.diagram.messages import DiagramOpenRequested
|
|
49
|
+
from chrys.app.tui.widgets.markdown.diagram.model import CompiledDiagram
|
|
50
|
+
from chrys.app.tui.widgets.markdown.links import external_link_target
|
|
51
|
+
from chrys.app.tui.widgets.markdown.math import CompiledMath, compile_math
|
|
52
|
+
from chrys.app.tui.widgets.markdown.math.markdown import MATH_ENABLED
|
|
53
|
+
from chrys.app.tui.widgets.markdown.math.parser import MAX_SOURCE as MAX_MATH_SOURCE
|
|
54
|
+
from chrys.app.tui.widgets.markdown.parser import (
|
|
55
|
+
_cell_min_width,
|
|
56
|
+
_cell_natural_width,
|
|
57
|
+
_cell_prefix_length,
|
|
58
|
+
_cell_wrapped_height,
|
|
59
|
+
_configure_markdown_parser,
|
|
60
|
+
_create_markdown_parser,
|
|
61
|
+
_parse_tokens,
|
|
62
|
+
_wrap_cell_text,
|
|
63
|
+
)
|
|
64
|
+
from chrys.app.tui.widgets.markdown.stream import MarkdownStream
|
|
65
|
+
from chrys.foundation.i18n import msg
|
|
66
|
+
|
|
67
|
+
_DIAGRAM_OPEN = msg("tui.markdown.diagram.open", fallback="Open full diagram")
|
|
68
|
+
_INLINE_DIAGRAM_MAX_HEIGHT = 24
|
|
69
|
+
_DIAGRAM_COMPILE_CACHE_SIZE = 24
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _strip_plain_text(strip: Strip) -> str:
|
|
73
|
+
"""Extract plain text from a Strip by joining segment texts."""
|
|
74
|
+
return "".join(seg.text for seg in strip._segments)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _line_ranges(text: str) -> list[tuple[int, int]]:
|
|
78
|
+
"""Return newline-exclusive character ranges for every source line."""
|
|
79
|
+
if not text:
|
|
80
|
+
return [(0, 0)]
|
|
81
|
+
|
|
82
|
+
ranges: list[tuple[int, int]] = []
|
|
83
|
+
start = 0
|
|
84
|
+
while True:
|
|
85
|
+
newline = text.find("\n", start)
|
|
86
|
+
if newline == -1:
|
|
87
|
+
ranges.append((start, len(text)))
|
|
88
|
+
return ranges
|
|
89
|
+
ranges.append((start, newline))
|
|
90
|
+
start = newline + 1
|
|
91
|
+
if start == len(text):
|
|
92
|
+
ranges.append((start, start))
|
|
93
|
+
return ranges
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _content_slice(content: Content, start: int, end: int, span_indices: list[int] | None = None) -> Content:
|
|
97
|
+
"""Return a slice of ``content`` while preserving overlapping spans."""
|
|
98
|
+
text = content.plain[start:end]
|
|
99
|
+
if not content.spans:
|
|
100
|
+
return Content(text, strip_control_codes=False)
|
|
101
|
+
|
|
102
|
+
spans: list[Span] = []
|
|
103
|
+
source_spans = content.spans
|
|
104
|
+
indices = span_indices if span_indices is not None else range(len(source_spans))
|
|
105
|
+
for span_index in indices:
|
|
106
|
+
span_start, span_end, style = source_spans[span_index]
|
|
107
|
+
if span_end <= start:
|
|
108
|
+
continue
|
|
109
|
+
if span_start >= end:
|
|
110
|
+
continue
|
|
111
|
+
slice_start = max(span_start, start) - start
|
|
112
|
+
slice_end = min(span_end, end) - start
|
|
113
|
+
if slice_end > slice_start:
|
|
114
|
+
spans.append(Span(slice_start, slice_end, style))
|
|
115
|
+
return Content(text, spans, strip_control_codes=False)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _span_indices_by_line(spans: Sequence[Span], line_ranges: list[tuple[int, int]]) -> list[list[int]] | None:
|
|
119
|
+
"""Return candidate span indices for each source line."""
|
|
120
|
+
if not spans:
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
line_starts = [start for start, _end in line_ranges]
|
|
124
|
+
line_ends = [end for _start, end in line_ranges]
|
|
125
|
+
indices_by_line: list[list[int]] = [[] for _ in line_ranges]
|
|
126
|
+
for span_index, span in enumerate(spans):
|
|
127
|
+
first_line = bisect_right(line_ends, span.start)
|
|
128
|
+
last_line = bisect_left(line_starts, span.end)
|
|
129
|
+
for line_index in range(first_line, last_line):
|
|
130
|
+
indices_by_line[line_index].append(span_index)
|
|
131
|
+
return indices_by_line
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _shrink_widest_columns(natural: list[int], floors: list[int], available: int) -> list[int]:
|
|
135
|
+
"""Fit column widths into exactly *available* cells, shrinking the widest columns first.
|
|
136
|
+
|
|
137
|
+
Every column is capped at one shared width, the largest that fits, so a short column
|
|
138
|
+
keeps its natural width while the long ones wrap; the cells the cap leaves over go to
|
|
139
|
+
the capped columns one each. Shrinking every column in proportion to its natural
|
|
140
|
+
width instead squeezed a three-glyph "Default" column down to two glyphs a line next
|
|
141
|
+
to a long description. Requires ``sum(floors) <= available < sum(natural)``.
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
def total_at(cap: int) -> int:
|
|
145
|
+
return sum(max(floor, min(width, cap)) for width, floor in zip(natural, floors, strict=True))
|
|
146
|
+
|
|
147
|
+
low, high = 1, max(natural)
|
|
148
|
+
while low < high:
|
|
149
|
+
middle = (low + high + 1) // 2
|
|
150
|
+
if total_at(middle) <= available:
|
|
151
|
+
low = middle
|
|
152
|
+
else:
|
|
153
|
+
high = middle - 1
|
|
154
|
+
widths = [max(floor, min(width, low)) for width, floor in zip(natural, floors, strict=True)]
|
|
155
|
+
spare = available - sum(widths)
|
|
156
|
+
for index, width in enumerate(natural):
|
|
157
|
+
if spare <= 0:
|
|
158
|
+
break
|
|
159
|
+
if widths[index] < width:
|
|
160
|
+
widths[index] += 1
|
|
161
|
+
spare -= 1
|
|
162
|
+
return widths
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@dataclass
|
|
166
|
+
class _TableLayout:
|
|
167
|
+
"""Width and height metadata for a table block."""
|
|
168
|
+
|
|
169
|
+
col_widths: list[int]
|
|
170
|
+
header_height: int
|
|
171
|
+
row_heights: list[int]
|
|
172
|
+
row_starts: list[int]
|
|
173
|
+
total_height: int
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@dataclass
|
|
177
|
+
class _FenceLayout:
|
|
178
|
+
"""Source-line layout metadata for a fenced code block."""
|
|
179
|
+
|
|
180
|
+
line_ranges: list[tuple[int, int]]
|
|
181
|
+
line_span_indices: list[list[int]] | None
|
|
182
|
+
line_starts: list[int]
|
|
183
|
+
total_height: int
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@dataclass(frozen=True, slots=True)
|
|
187
|
+
class _DiagramLayout:
|
|
188
|
+
"""Viewport-independent diagram metadata for one Markdown layout pass."""
|
|
189
|
+
|
|
190
|
+
preview_height: int
|
|
191
|
+
|
|
192
|
+
@property
|
|
193
|
+
def total_height(self) -> int:
|
|
194
|
+
return self.preview_height + 1
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
class _PendingParse:
|
|
198
|
+
"""The parse that ``update()``/``append()`` scheduled and that has not read the source yet.
|
|
199
|
+
|
|
200
|
+
Requests made before it takes the widget's lock join it rather than
|
|
201
|
+
scheduling a parse of their own: it parses whatever source is current once
|
|
202
|
+
it holds the lock, so it installs text at least as new as every joined
|
|
203
|
+
request's.
|
|
204
|
+
"""
|
|
205
|
+
|
|
206
|
+
__slots__ = ("announce_contents", "task")
|
|
207
|
+
|
|
208
|
+
def __init__(
|
|
209
|
+
self,
|
|
210
|
+
run: Callable[[_PendingParse], Coroutine[Any, Any, None]],
|
|
211
|
+
*,
|
|
212
|
+
announce_contents: bool,
|
|
213
|
+
) -> None:
|
|
214
|
+
loop = asyncio.get_running_loop()
|
|
215
|
+
self.announce_contents = announce_contents
|
|
216
|
+
"""Post ``TableOfContentsUpdated`` even without headings: an ``update()`` joined."""
|
|
217
|
+
self.task: asyncio.Task[None] = loop.create_task(run(self))
|
|
218
|
+
"""The parse; awaiters get it shielded, so no single caller can cancel it for the others."""
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
class VirtualizedMarkdown(ScrollView, can_focus=True):
|
|
222
|
+
"""A Markdown widget that uses virtualization for efficient rendering.
|
|
223
|
+
|
|
224
|
+
This widget parses markdown and renders it using the Line API,
|
|
225
|
+
only rendering visible lines for optimal performance with large documents.
|
|
226
|
+
"""
|
|
227
|
+
|
|
228
|
+
COMPONENT_CLASSES: ClassVar[set[str]] = {
|
|
229
|
+
"virtualized-markdown--h1",
|
|
230
|
+
"virtualized-markdown--h2",
|
|
231
|
+
"virtualized-markdown--h3",
|
|
232
|
+
"virtualized-markdown--h4",
|
|
233
|
+
"virtualized-markdown--h5",
|
|
234
|
+
"virtualized-markdown--h6",
|
|
235
|
+
"virtualized-markdown--paragraph",
|
|
236
|
+
"virtualized-markdown--fence",
|
|
237
|
+
"virtualized-markdown--diagram",
|
|
238
|
+
"virtualized-markdown--diagram-action",
|
|
239
|
+
"virtualized-markdown--hr",
|
|
240
|
+
"virtualized-markdown--table",
|
|
241
|
+
"virtualized-markdown--table-header",
|
|
242
|
+
"virtualized-markdown--block-quote",
|
|
243
|
+
"virtualized-markdown--block-quote-border",
|
|
244
|
+
"virtualized-markdown--bullet",
|
|
245
|
+
"code_inline",
|
|
246
|
+
"em",
|
|
247
|
+
"strong",
|
|
248
|
+
"s",
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
DEFAULT_CSS = """
|
|
252
|
+
VirtualizedMarkdown {
|
|
253
|
+
color: $foreground;
|
|
254
|
+
overflow-y: auto;
|
|
255
|
+
overflow-x: hidden;
|
|
256
|
+
background: $surface;
|
|
257
|
+
padding: 0 2 0 2;
|
|
258
|
+
|
|
259
|
+
& > .virtualized-markdown--h1 {
|
|
260
|
+
color: $markdown-h1-color;
|
|
261
|
+
background: $markdown-h1-background;
|
|
262
|
+
text-style: $markdown-h1-text-style;
|
|
263
|
+
content-align: center middle;
|
|
264
|
+
}
|
|
265
|
+
& > .virtualized-markdown--h2 {
|
|
266
|
+
color: $markdown-h2-color;
|
|
267
|
+
background: $markdown-h2-background;
|
|
268
|
+
text-style: $markdown-h2-text-style;
|
|
269
|
+
}
|
|
270
|
+
& > .virtualized-markdown--h3 {
|
|
271
|
+
color: $markdown-h3-color;
|
|
272
|
+
background: $markdown-h3-background;
|
|
273
|
+
text-style: $markdown-h3-text-style;
|
|
274
|
+
}
|
|
275
|
+
& > .virtualized-markdown--h4 {
|
|
276
|
+
color: $markdown-h4-color;
|
|
277
|
+
background: $markdown-h4-background;
|
|
278
|
+
text-style: $markdown-h4-text-style;
|
|
279
|
+
}
|
|
280
|
+
& > .virtualized-markdown--h5 {
|
|
281
|
+
color: $markdown-h5-color;
|
|
282
|
+
background: $markdown-h5-background;
|
|
283
|
+
text-style: $markdown-h5-text-style;
|
|
284
|
+
}
|
|
285
|
+
& > .virtualized-markdown--h6 {
|
|
286
|
+
color: $markdown-h6-color;
|
|
287
|
+
background: $markdown-h6-background;
|
|
288
|
+
text-style: $markdown-h6-text-style;
|
|
289
|
+
}
|
|
290
|
+
& > .virtualized-markdown--fence {
|
|
291
|
+
background: black 10%;
|
|
292
|
+
color: $text;
|
|
293
|
+
}
|
|
294
|
+
&:light > .virtualized-markdown--fence {
|
|
295
|
+
background: white 30%;
|
|
296
|
+
}
|
|
297
|
+
& > .virtualized-markdown--diagram {
|
|
298
|
+
background: $surface-lighten-1;
|
|
299
|
+
color: $foreground;
|
|
300
|
+
}
|
|
301
|
+
& > .virtualized-markdown--diagram-action {
|
|
302
|
+
color: $primary;
|
|
303
|
+
text-style: underline;
|
|
304
|
+
}
|
|
305
|
+
& > .virtualized-markdown--hr {
|
|
306
|
+
color: $secondary;
|
|
307
|
+
}
|
|
308
|
+
& > .virtualized-markdown--block-quote {
|
|
309
|
+
}
|
|
310
|
+
&:dark > .virtualized-markdown--block-quote-border {
|
|
311
|
+
color: $text-primary 50%;
|
|
312
|
+
}
|
|
313
|
+
&:light > .virtualized-markdown--block-quote-border {
|
|
314
|
+
color: $text-secondary;
|
|
315
|
+
}
|
|
316
|
+
&:dark > .virtualized-markdown--bullet {
|
|
317
|
+
color: $text-primary;
|
|
318
|
+
}
|
|
319
|
+
&:light > .virtualized-markdown--bullet {
|
|
320
|
+
color: $text-secondary;
|
|
321
|
+
}
|
|
322
|
+
& > .virtualized-markdown--table {
|
|
323
|
+
}
|
|
324
|
+
&:light > .virtualized-markdown--table {
|
|
325
|
+
background: white 30%;
|
|
326
|
+
}
|
|
327
|
+
& > .virtualized-markdown--table-header {
|
|
328
|
+
color: $primary;
|
|
329
|
+
text-style: bold;
|
|
330
|
+
}
|
|
331
|
+
&:dark > .code_inline {
|
|
332
|
+
background: $warning 10%;
|
|
333
|
+
color: $text-warning 95%;
|
|
334
|
+
}
|
|
335
|
+
&:light > .code_inline {
|
|
336
|
+
background: $error 5%;
|
|
337
|
+
color: $text-error 95%;
|
|
338
|
+
}
|
|
339
|
+
& > .em {
|
|
340
|
+
text-style: italic;
|
|
341
|
+
}
|
|
342
|
+
& > .strong {
|
|
343
|
+
text-style: bold;
|
|
344
|
+
}
|
|
345
|
+
& > .s {
|
|
346
|
+
text-style: strike;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
"""
|
|
350
|
+
|
|
351
|
+
class TableOfContentsUpdated(Message):
|
|
352
|
+
"""The table of contents was updated."""
|
|
353
|
+
|
|
354
|
+
def __init__(self, markdown: VirtualizedMarkdown, table_of_contents: TableOfContentsType | None) -> None:
|
|
355
|
+
super().__init__()
|
|
356
|
+
self.markdown: VirtualizedMarkdown = markdown
|
|
357
|
+
"""The `VirtualizedMarkdown` widget associated with the table of contents."""
|
|
358
|
+
self.table_of_contents: TableOfContentsType | None = table_of_contents
|
|
359
|
+
"""Table of contents."""
|
|
360
|
+
|
|
361
|
+
@property
|
|
362
|
+
def control(self) -> VirtualizedMarkdown:
|
|
363
|
+
"""The `VirtualizedMarkdown` widget associated with the table of contents."""
|
|
364
|
+
return self.markdown
|
|
365
|
+
|
|
366
|
+
class TableOfContentsSelected(Message):
|
|
367
|
+
"""An item in the TOC was selected."""
|
|
368
|
+
|
|
369
|
+
def __init__(self, markdown: VirtualizedMarkdown, block_id: str) -> None:
|
|
370
|
+
super().__init__()
|
|
371
|
+
self.markdown: VirtualizedMarkdown = markdown
|
|
372
|
+
"""The `VirtualizedMarkdown` widget where the selected item is."""
|
|
373
|
+
self.block_id: str = block_id
|
|
374
|
+
"""ID of the block that was selected."""
|
|
375
|
+
|
|
376
|
+
@property
|
|
377
|
+
def control(self) -> VirtualizedMarkdown:
|
|
378
|
+
"""The `VirtualizedMarkdown` widget where the selected item is."""
|
|
379
|
+
return self.markdown
|
|
380
|
+
|
|
381
|
+
class LinkClicked(Message):
|
|
382
|
+
"""A link in the document was clicked."""
|
|
383
|
+
|
|
384
|
+
def __init__(self, markdown: VirtualizedMarkdown, href: str) -> None:
|
|
385
|
+
super().__init__()
|
|
386
|
+
self.markdown: VirtualizedMarkdown = markdown
|
|
387
|
+
"""The `VirtualizedMarkdown` widget containing the link clicked."""
|
|
388
|
+
self.raw_href: str = href
|
|
389
|
+
"""The original link target, retaining percent-encoding for external opening."""
|
|
390
|
+
self.href: str = unquote(href)
|
|
391
|
+
"""The decoded link target for local document and anchor navigation."""
|
|
392
|
+
|
|
393
|
+
@property
|
|
394
|
+
def control(self) -> VirtualizedMarkdown:
|
|
395
|
+
"""The `VirtualizedMarkdown` widget containing the link clicked."""
|
|
396
|
+
return self.markdown
|
|
397
|
+
|
|
398
|
+
def __init__(
|
|
399
|
+
self,
|
|
400
|
+
markdown: str | None = None,
|
|
401
|
+
*,
|
|
402
|
+
name: str | None = None,
|
|
403
|
+
id: str | None = None,
|
|
404
|
+
classes: str | None = None,
|
|
405
|
+
parser_factory: Callable[[], MarkdownIt] | None = None,
|
|
406
|
+
open_links: bool = True,
|
|
407
|
+
):
|
|
408
|
+
"""A VirtualizedMarkdown widget.
|
|
409
|
+
|
|
410
|
+
Args:
|
|
411
|
+
markdown: String containing Markdown or None to leave blank for now.
|
|
412
|
+
name: The name of the widget.
|
|
413
|
+
id: The ID of the widget in the DOM.
|
|
414
|
+
classes: The CSS classes of the widget.
|
|
415
|
+
parser_factory: A factory function to return a configured MarkdownIt instance. If `None`, a "gfm-like" parser is used.
|
|
416
|
+
open_links: Open external links on click and expose terminal hyperlinks. If `False`,
|
|
417
|
+
only LinkClicked events and internal anchor navigation remain enabled.
|
|
418
|
+
"""
|
|
419
|
+
super().__init__(name=name, id=id, classes=classes)
|
|
420
|
+
self._initial_markdown: str | None = markdown
|
|
421
|
+
self._markdown = ""
|
|
422
|
+
self._parser_factory = parser_factory
|
|
423
|
+
self._table_of_contents: TableOfContentsType | None = None
|
|
424
|
+
self._open_links = open_links
|
|
425
|
+
self._last_parsed_line = 0
|
|
426
|
+
self._pending_parse: _PendingParse | None = None
|
|
427
|
+
"""The scheduled parse that has not read ``_markdown`` yet; new requests join it."""
|
|
428
|
+
|
|
429
|
+
# Virtualization state
|
|
430
|
+
self._blocks: list[MarkdownBlock] = []
|
|
431
|
+
"""Parsed markdown blocks."""
|
|
432
|
+
self._block_line_info: list[_BlockLineInfo] = []
|
|
433
|
+
"""Line information for each block."""
|
|
434
|
+
self._total_lines: int = 0
|
|
435
|
+
"""Total virtual lines."""
|
|
436
|
+
self._line_cache: LRUCache[tuple[int, int], Strip] = LRUCache(maxsize=2048)
|
|
437
|
+
"""Cache of rendered strips, keyed by (line_number, width)."""
|
|
438
|
+
self._table_layouts: dict[int, _TableLayout] = {}
|
|
439
|
+
"""Layout metadata for table blocks, keyed by block index."""
|
|
440
|
+
self._table_strips: LRUCache[tuple[int, int], Strip] = LRUCache(maxsize=4096)
|
|
441
|
+
"""Lazily-rendered table strips, keyed by (block index, content line)."""
|
|
442
|
+
self._fence_layouts: dict[int, _FenceLayout] = {}
|
|
443
|
+
"""Source-line layout metadata for fenced code blocks, keyed by block index."""
|
|
444
|
+
self._fence_line_strips: LRUCache[tuple[int, int], Strip] = LRUCache(maxsize=4096)
|
|
445
|
+
"""Lazily-rendered fence strips, keyed by (block index, content line)."""
|
|
446
|
+
self._fence_source_line_strips: LRUCache[tuple[int, int], list[Strip]] = LRUCache(maxsize=1024)
|
|
447
|
+
"""Wrapped source-line strips for fence lines, keyed by (block index, source line)."""
|
|
448
|
+
self._diagram_layouts: dict[int, _DiagramLayout] = {}
|
|
449
|
+
"""Inline preview layout metadata for diagram blocks."""
|
|
450
|
+
self._diagram_action_lines: dict[int, int] = {}
|
|
451
|
+
"""Virtual action line to diagram block index."""
|
|
452
|
+
self._diagram_action_widths: dict[int, int] = {}
|
|
453
|
+
"""Rendered action widths keyed by virtual line, kept in sync with cached strips."""
|
|
454
|
+
self._diagram_compile_cache: OrderedDict[tuple[str, str], CompiledDiagram] = OrderedDict()
|
|
455
|
+
"""Small per-widget cache reused across streaming Markdown reparses."""
|
|
456
|
+
self._math_compile_cache: OrderedDict[str, CompiledMath] = OrderedDict()
|
|
457
|
+
self._math_rows: dict[int, tuple[str, ...]] = {}
|
|
458
|
+
self._block_strips: dict[int, list[Strip]] = {}
|
|
459
|
+
"""Cached strips for long non-table/fence blocks, keyed by block index.
|
|
460
|
+
|
|
461
|
+
Paragraphs and similar blocks have the same cold-scroll shape as
|
|
462
|
+
fences: ``Content.render_strips`` renders the entire wrapped block even
|
|
463
|
+
when callers need a single row. Cache only blocks taller than the
|
|
464
|
+
visible crop so short blocks still rely on the smaller per-line cache."""
|
|
465
|
+
self._block_style_cache: dict[int, Style] = {}
|
|
466
|
+
"""Resolved per-block visual style. Invalidated on theme / parse changes.
|
|
467
|
+
|
|
468
|
+
``_render_block_line`` runs once per cold row, and each call would
|
|
469
|
+
otherwise walk the CSS component-style chain (``_get_block_style`` to
|
|
470
|
+
``_safe_component_style`` to ``get_visual_style``). Memoizing per
|
|
471
|
+
block index keeps that walk to at most once per block per layout."""
|
|
472
|
+
self._width_at_last_layout: int = 0
|
|
473
|
+
"""Width when blocks were last laid out."""
|
|
474
|
+
self._plain_selection_cache: tuple[list[str], list[tuple[str, int]]] | None = None
|
|
475
|
+
"""Cached plain text rows and inter-row separators for text selection / copy.
|
|
476
|
+
Built lazily from rendered strips on first get_selection call."""
|
|
477
|
+
self._frame_scroll_y: int = 0
|
|
478
|
+
self._frame_width: int = 0
|
|
479
|
+
self._frame_visible_height: int = 0
|
|
480
|
+
self._frame_selection: object = None
|
|
481
|
+
self._lines_cache_key: tuple[int, int, int, int, int, int, str, str] = (
|
|
482
|
+
-1,
|
|
483
|
+
-1,
|
|
484
|
+
-1,
|
|
485
|
+
-1,
|
|
486
|
+
-1,
|
|
487
|
+
-1,
|
|
488
|
+
"",
|
|
489
|
+
"",
|
|
490
|
+
)
|
|
491
|
+
self._lines_cache_result: list[Strip] | None = None
|
|
492
|
+
|
|
493
|
+
# Seed ``virtual_size`` with a synchronous estimate so the widget
|
|
494
|
+
# never reports ``(0, 0)`` between mount and ``_on_mount``'s async
|
|
495
|
+
# ``update()``. Without this, an outside reflow that lands inside
|
|
496
|
+
# the executor await (e.g. ApprovalDialog dismiss on Windows) sees
|
|
497
|
+
# height 0 and causes the parent ChatPanel's anchor pin to compute
|
|
498
|
+
# against a transiently short ``virtual_h`` — visible to the user
|
|
499
|
+
# as a chat scroll-up / scroll-to-top. The estimate is a lower
|
|
500
|
+
# bound (no wrap counted) — ``_layout_blocks`` overrides with the
|
|
501
|
+
# real value once width is known. ``set_reactive`` skips watchers
|
|
502
|
+
# which is required pre-mount.
|
|
503
|
+
if markdown:
|
|
504
|
+
line_estimate = max(1, markdown.count("\n") + 1)
|
|
505
|
+
self.set_reactive(Widget.virtual_size, Size(80, line_estimate))
|
|
506
|
+
|
|
507
|
+
@property
|
|
508
|
+
def allow_select(self) -> bool:
|
|
509
|
+
"""Allow text selection even though ScrollView is a container."""
|
|
510
|
+
return True
|
|
511
|
+
|
|
512
|
+
@property
|
|
513
|
+
def table_of_contents(self) -> TableOfContentsType:
|
|
514
|
+
"""The document's table of contents."""
|
|
515
|
+
if self._table_of_contents is None:
|
|
516
|
+
self._table_of_contents = [
|
|
517
|
+
(block.level, block.content.plain, block.block_id)
|
|
518
|
+
for block in self._blocks
|
|
519
|
+
if block.block_type == "heading"
|
|
520
|
+
]
|
|
521
|
+
return self._table_of_contents
|
|
522
|
+
|
|
523
|
+
@property
|
|
524
|
+
def source(self) -> str:
|
|
525
|
+
"""The markdown source."""
|
|
526
|
+
return self._markdown or ""
|
|
527
|
+
|
|
528
|
+
async def _on_mount(self, _: Mount) -> None: # ty: ignore[invalid-method-override] # Textual dispatch awaits async handlers.
|
|
529
|
+
initial_markdown = self._initial_markdown
|
|
530
|
+
self._initial_markdown = None
|
|
531
|
+
await self.update(initial_markdown or "")
|
|
532
|
+
|
|
533
|
+
if initial_markdown is None:
|
|
534
|
+
self.post_message(
|
|
535
|
+
VirtualizedMarkdown.TableOfContentsUpdated(self, self._table_of_contents).set_sender(self)
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
@classmethod
|
|
539
|
+
def get_stream(cls, markdown: VirtualizedMarkdown) -> MarkdownStream:
|
|
540
|
+
"""Get a MarkdownStream instance.
|
|
541
|
+
|
|
542
|
+
Args:
|
|
543
|
+
markdown: A VirtualizedMarkdown widget instance.
|
|
544
|
+
|
|
545
|
+
Returns:
|
|
546
|
+
The Markdown stream object.
|
|
547
|
+
"""
|
|
548
|
+
updater = MarkdownStream(markdown)
|
|
549
|
+
updater.start()
|
|
550
|
+
return updater
|
|
551
|
+
|
|
552
|
+
def prepare_for_gc_freeze(self) -> None:
|
|
553
|
+
"""Detach cyclic render LRUs before the permanent generation changes."""
|
|
554
|
+
# gc-freeze swaps in an acyclic capacity token; renew restores a real cache before next use.
|
|
555
|
+
self._line_cache = detach_lru_cache(self._line_cache) # ty: ignore[invalid-assignment]
|
|
556
|
+
self._table_strips = detach_lru_cache(self._table_strips) # ty: ignore[invalid-assignment]
|
|
557
|
+
self._fence_line_strips = detach_lru_cache(self._fence_line_strips) # ty: ignore[invalid-assignment]
|
|
558
|
+
self._fence_source_line_strips = detach_lru_cache(self._fence_source_line_strips) # ty: ignore[invalid-assignment]
|
|
559
|
+
self._lines_cache_result = None
|
|
560
|
+
|
|
561
|
+
def after_gc_freeze(self) -> None:
|
|
562
|
+
"""Recreate render LRUs after the permanent generation changes."""
|
|
563
|
+
self._line_cache = renew_lru_cache(self._line_cache)
|
|
564
|
+
self._table_strips = renew_lru_cache(self._table_strips)
|
|
565
|
+
self._fence_line_strips = renew_lru_cache(self._fence_line_strips)
|
|
566
|
+
self._fence_source_line_strips = renew_lru_cache(self._fence_source_line_strips)
|
|
567
|
+
|
|
568
|
+
def abort_gc_freeze(self) -> None:
|
|
569
|
+
"""Restore any render LRUs left detached by an incomplete hook pass."""
|
|
570
|
+
self.after_gc_freeze()
|
|
571
|
+
|
|
572
|
+
def on_virtualized_markdown_link_clicked(self, event: LinkClicked) -> None:
|
|
573
|
+
href = event.href
|
|
574
|
+
if href.startswith("#"):
|
|
575
|
+
self.goto_anchor(href[1:])
|
|
576
|
+
elif self._open_links and (target := external_link_target(event.raw_href)) is not None:
|
|
577
|
+
self.app.open_url(target)
|
|
578
|
+
|
|
579
|
+
def on_click(self, event: Click) -> None:
|
|
580
|
+
"""Open a diagram only when its dedicated action row is clicked."""
|
|
581
|
+
block_index = self._diagram_action_at(event.get_content_offset(self))
|
|
582
|
+
if block_index is None:
|
|
583
|
+
return
|
|
584
|
+
self._request_diagram_open(block_index)
|
|
585
|
+
event.prevent_default()
|
|
586
|
+
event.stop()
|
|
587
|
+
|
|
588
|
+
def _diagram_action_at(self, offset: Offset | None) -> int | None:
|
|
589
|
+
"""Return the diagram action under a viewport-relative content coordinate."""
|
|
590
|
+
if offset is None:
|
|
591
|
+
return None
|
|
592
|
+
virtual_y = round(self.scroll_offset.y) + offset.y
|
|
593
|
+
block_index = self._diagram_action_lines.get(virtual_y)
|
|
594
|
+
if block_index is None:
|
|
595
|
+
return None
|
|
596
|
+
block = self._blocks[block_index]
|
|
597
|
+
border_width = len(block.border_left) if block.border_left else 0
|
|
598
|
+
left_offset = block.indent + block.padding_left + border_width
|
|
599
|
+
content_width = (
|
|
600
|
+
self._width_at_last_layout - block.indent - block.padding_left - block.padding_right - border_width - 1
|
|
601
|
+
)
|
|
602
|
+
if content_width <= 0:
|
|
603
|
+
content_width = 1
|
|
604
|
+
# Use the width recorded when the cached strip was rendered. Transcript
|
|
605
|
+
# rows deliberately keep their existing language across a live locale
|
|
606
|
+
# switch, so resolving the new translation here would desynchronize the
|
|
607
|
+
# hitbox from the still-visible old-language action.
|
|
608
|
+
action_width = self._diagram_action_widths.get(virtual_y)
|
|
609
|
+
if action_width is None:
|
|
610
|
+
action_text = render_str(widget_localizer(self), _DIAGRAM_OPEN.bind())
|
|
611
|
+
action_width = min(content_width, cell_len(action_text))
|
|
612
|
+
action_start = left_offset + content_width - action_width
|
|
613
|
+
return block_index if action_start <= offset.x < action_start + action_width else None
|
|
614
|
+
|
|
615
|
+
def on_mouse_move(self, event: MouseMove) -> None:
|
|
616
|
+
pointer = "pointer" if self._diagram_action_at(event.get_content_offset(self)) is not None else "default"
|
|
617
|
+
if self.styles.pointer != pointer:
|
|
618
|
+
self.styles.pointer = pointer
|
|
619
|
+
|
|
620
|
+
def on_leave(self, _event: Leave) -> None:
|
|
621
|
+
if self.styles.pointer != "default":
|
|
622
|
+
self.styles.pointer = "default"
|
|
623
|
+
|
|
624
|
+
def _request_diagram_open(self, block_index: int) -> bool:
|
|
625
|
+
"""Post an expanded-view request for one compiled diagram block."""
|
|
626
|
+
if block_index < 0 or block_index >= len(self._blocks):
|
|
627
|
+
return False
|
|
628
|
+
diagram = self._blocks[block_index].diagram
|
|
629
|
+
if diagram is None:
|
|
630
|
+
return False
|
|
631
|
+
self.post_message(DiagramOpenRequested(diagram))
|
|
632
|
+
return True
|
|
633
|
+
|
|
634
|
+
@staticmethod
|
|
635
|
+
def sanitize_location(location: str) -> tuple[Path, str]:
|
|
636
|
+
"""Given a location, break out the path and any anchor.
|
|
637
|
+
|
|
638
|
+
Args:
|
|
639
|
+
location: The location to sanitize.
|
|
640
|
+
|
|
641
|
+
Returns:
|
|
642
|
+
A tuple of the path to the location cleaned of any anchor, plus
|
|
643
|
+
the anchor (or an empty string if none was found).
|
|
644
|
+
"""
|
|
645
|
+
return _sanitize_location(location)
|
|
646
|
+
|
|
647
|
+
def goto_anchor(self, anchor: str) -> bool:
|
|
648
|
+
"""Try and find the given anchor in the current document.
|
|
649
|
+
|
|
650
|
+
Args:
|
|
651
|
+
anchor: The anchor to try and find.
|
|
652
|
+
|
|
653
|
+
Returns:
|
|
654
|
+
True when the anchor was found, False otherwise.
|
|
655
|
+
"""
|
|
656
|
+
if self._table_of_contents is None:
|
|
657
|
+
return False
|
|
658
|
+
found = find_heading([title for _, title, _ in self._table_of_contents], anchor)
|
|
659
|
+
if found is None:
|
|
660
|
+
return False
|
|
661
|
+
header_id = self._table_of_contents[found][2]
|
|
662
|
+
for info in self._block_line_info:
|
|
663
|
+
block = self._blocks[info.block_index]
|
|
664
|
+
if block.block_id == header_id:
|
|
665
|
+
self.scroll_to(y=info.start_line, animate=False)
|
|
666
|
+
return True
|
|
667
|
+
return True
|
|
668
|
+
|
|
669
|
+
async def load(self, path: Path) -> None:
|
|
670
|
+
"""Load a new Markdown document.
|
|
671
|
+
|
|
672
|
+
Args:
|
|
673
|
+
path: Path to the document.
|
|
674
|
+
|
|
675
|
+
Raises:
|
|
676
|
+
OSError: If there was some form of error loading the document.
|
|
677
|
+
"""
|
|
678
|
+
path, anchor = self.sanitize_location(str(path))
|
|
679
|
+
data = await asyncio.get_running_loop().run_in_executor(None, partial(path.read_text, encoding="utf-8"))
|
|
680
|
+
await self.update(data)
|
|
681
|
+
if anchor:
|
|
682
|
+
self.goto_anchor(anchor)
|
|
683
|
+
|
|
684
|
+
def _build_blocks(self, markdown: str) -> list[MarkdownBlock]:
|
|
685
|
+
"""Parse markdown source into blocks.
|
|
686
|
+
|
|
687
|
+
Args:
|
|
688
|
+
markdown: Markdown document string.
|
|
689
|
+
|
|
690
|
+
Returns:
|
|
691
|
+
A list of MarkdownBlock objects.
|
|
692
|
+
"""
|
|
693
|
+
parser = (
|
|
694
|
+
_create_markdown_parser()
|
|
695
|
+
if self._parser_factory is None
|
|
696
|
+
else _configure_markdown_parser(self._parser_factory())
|
|
697
|
+
)
|
|
698
|
+
tokens = parser.parse(markdown)
|
|
699
|
+
return _parse_tokens(
|
|
700
|
+
tokens,
|
|
701
|
+
compile_diagram=self._compile_mermaid_cached,
|
|
702
|
+
open_links=self._open_links,
|
|
703
|
+
math_enabled=bool(parser.options.get(MATH_ENABLED, False)),
|
|
704
|
+
math_compiler=self._compile_math_cached,
|
|
705
|
+
)
|
|
706
|
+
|
|
707
|
+
def _compile_math_cached(self, source: str) -> CompiledMath:
|
|
708
|
+
"""Bound both successful and failed compilations across streamed reparses."""
|
|
709
|
+
if len(source) > MAX_MATH_SOURCE:
|
|
710
|
+
return CompiledMath(source)
|
|
711
|
+
cached = self._math_compile_cache.get(source)
|
|
712
|
+
if cached is not None:
|
|
713
|
+
self._math_compile_cache.move_to_end(source)
|
|
714
|
+
return cached
|
|
715
|
+
result = compile_math(source)
|
|
716
|
+
self._math_compile_cache[source] = result
|
|
717
|
+
while len(self._math_compile_cache) > 64:
|
|
718
|
+
self._math_compile_cache.popitem(last=False)
|
|
719
|
+
return result
|
|
720
|
+
|
|
721
|
+
def _compile_mermaid_cached(self, source: str) -> CompiledDiagram:
|
|
722
|
+
"""Compile one closed Mermaid fence, reusing stable streamed blocks."""
|
|
723
|
+
localizer = widget_localizer(self)
|
|
724
|
+
cache_key = (localizer.effective_locale, source)
|
|
725
|
+
cached = self._diagram_compile_cache.get(cache_key)
|
|
726
|
+
if cached is not None:
|
|
727
|
+
self._diagram_compile_cache.move_to_end(cache_key)
|
|
728
|
+
return cached
|
|
729
|
+
|
|
730
|
+
diagram = compile_mermaid(source, render_message=localizer.render)
|
|
731
|
+
self._diagram_compile_cache[cache_key] = diagram
|
|
732
|
+
self._diagram_compile_cache.move_to_end(cache_key)
|
|
733
|
+
while len(self._diagram_compile_cache) > _DIAGRAM_COMPILE_CACHE_SIZE:
|
|
734
|
+
self._diagram_compile_cache.popitem(last=False)
|
|
735
|
+
return diagram
|
|
736
|
+
|
|
737
|
+
def _layout_blocks(self, width: int | None = None) -> None:
|
|
738
|
+
"""Compute the line layout for all blocks."""
|
|
739
|
+
if width is None:
|
|
740
|
+
width = self.scrollable_content_region.width
|
|
741
|
+
if width <= 0:
|
|
742
|
+
width = 80
|
|
743
|
+
|
|
744
|
+
self._width_at_last_layout = width
|
|
745
|
+
self._block_line_info.clear()
|
|
746
|
+
self._line_cache.clear()
|
|
747
|
+
self._table_layouts.clear()
|
|
748
|
+
self._table_strips.clear()
|
|
749
|
+
self._fence_layouts.clear()
|
|
750
|
+
self._fence_line_strips.clear()
|
|
751
|
+
self._fence_source_line_strips.clear()
|
|
752
|
+
self._diagram_layouts.clear()
|
|
753
|
+
self._math_rows.clear()
|
|
754
|
+
self._diagram_action_lines.clear()
|
|
755
|
+
self._diagram_action_widths.clear()
|
|
756
|
+
self._block_strips.clear()
|
|
757
|
+
self._block_style_cache.clear()
|
|
758
|
+
|
|
759
|
+
current_line = 0
|
|
760
|
+
last_bottom_margin = 0
|
|
761
|
+
render_rules = self.styles.get_render_rules()
|
|
762
|
+
|
|
763
|
+
for index, block in enumerate(self._blocks):
|
|
764
|
+
top_margin = max(block.top_margin, last_bottom_margin) - last_bottom_margin
|
|
765
|
+
if index == 0:
|
|
766
|
+
top_margin = 0
|
|
767
|
+
|
|
768
|
+
border_width = len(block.border_left) if block.border_left else 0
|
|
769
|
+
# -1 reserves the right margin char appended by _render_block_line
|
|
770
|
+
content_width = width - block.indent - block.padding_left - block.padding_right - border_width - 1
|
|
771
|
+
if content_width <= 0:
|
|
772
|
+
content_width = 1
|
|
773
|
+
|
|
774
|
+
if block.block_type == "hr":
|
|
775
|
+
content_height = 1
|
|
776
|
+
elif block.block_type == "table" and block.table_headers is not None:
|
|
777
|
+
table_layout = self._build_table_layout(block, content_width)
|
|
778
|
+
self._table_layouts[index] = table_layout
|
|
779
|
+
content_height = table_layout.total_height
|
|
780
|
+
elif block.block_type == "fence":
|
|
781
|
+
fence_layout = self._build_fence_layout(block, content_width, render_rules)
|
|
782
|
+
self._fence_layouts[index] = fence_layout
|
|
783
|
+
content_height = fence_layout.total_height
|
|
784
|
+
elif block.block_type == "diagram" and block.diagram is not None:
|
|
785
|
+
preview_height = min(block.diagram.height, _INLINE_DIAGRAM_MAX_HEIGHT)
|
|
786
|
+
diagram_layout = _DiagramLayout(preview_height=preview_height)
|
|
787
|
+
self._diagram_layouts[index] = diagram_layout
|
|
788
|
+
content_height = diagram_layout.total_height
|
|
789
|
+
elif block.block_type == "math" and block.math is not None:
|
|
790
|
+
if block.math.rows and block.math.width <= content_width:
|
|
791
|
+
self._math_rows[index] = block.math.rows
|
|
792
|
+
content_height = len(block.math.rows)
|
|
793
|
+
else:
|
|
794
|
+
# Source fallback uses the same wrapping and clipboard
|
|
795
|
+
# mapping as ordinary text, so soft wraps never split TeX.
|
|
796
|
+
content_height = block.content.get_height({}, content_width)
|
|
797
|
+
else:
|
|
798
|
+
content_height = block.content.get_height({}, content_width)
|
|
799
|
+
|
|
800
|
+
content_height += block.padding_top + block.padding_bottom
|
|
801
|
+
|
|
802
|
+
total_height = top_margin + content_height + block.bottom_margin
|
|
803
|
+
|
|
804
|
+
self._block_line_info.append(
|
|
805
|
+
_BlockLineInfo(
|
|
806
|
+
block_index=index,
|
|
807
|
+
start_line=current_line,
|
|
808
|
+
height=total_height,
|
|
809
|
+
content_height=content_height,
|
|
810
|
+
top_margin=top_margin,
|
|
811
|
+
bottom_margin=block.bottom_margin,
|
|
812
|
+
)
|
|
813
|
+
)
|
|
814
|
+
|
|
815
|
+
if block.block_type == "diagram" and index in self._diagram_layouts:
|
|
816
|
+
action_line = (
|
|
817
|
+
current_line + top_margin + block.padding_top + self._diagram_layouts[index].preview_height
|
|
818
|
+
)
|
|
819
|
+
self._diagram_action_lines[action_line] = index
|
|
820
|
+
|
|
821
|
+
current_line += total_height
|
|
822
|
+
last_bottom_margin = block.bottom_margin
|
|
823
|
+
|
|
824
|
+
self._plain_selection_cache = None
|
|
825
|
+
self._lines_cache_result = None
|
|
826
|
+
self._total_lines = current_line - last_bottom_margin
|
|
827
|
+
self.virtual_size = Size(width, self._total_lines)
|
|
828
|
+
self._refresh_scrollbars()
|
|
829
|
+
|
|
830
|
+
def _converge_layout(self) -> None:
|
|
831
|
+
"""Re-layout until the scrollbar-adjusted content width is stable.
|
|
832
|
+
|
|
833
|
+
``_layout_blocks`` sets ``virtual_size`` and refreshes the scrollbars,
|
|
834
|
+
which can toggle the vertical scrollbar and shift
|
|
835
|
+
``scrollable_content_region.width`` by one. Laying out a single time
|
|
836
|
+
would then paint strips (including cached table/block strips) sized for
|
|
837
|
+
the pre-scrollbar width into the post-scrollbar viewport for one frame —
|
|
838
|
+
the 1-column offset seen while resizing. Bounded so a borderline width
|
|
839
|
+
that flip-flops can't spin.
|
|
840
|
+
"""
|
|
841
|
+
if not self._blocks:
|
|
842
|
+
return
|
|
843
|
+
for _ in range(3):
|
|
844
|
+
width = self.scrollable_content_region.width
|
|
845
|
+
if width <= 0 or self._width_at_last_layout == width:
|
|
846
|
+
return
|
|
847
|
+
self._layout_blocks()
|
|
848
|
+
|
|
849
|
+
def on_resize(self, _event: Resize) -> None:
|
|
850
|
+
"""Eagerly update layout when the viewport width changes.
|
|
851
|
+
|
|
852
|
+
Waiting until ``render_lines`` to discover a new width is too late for
|
|
853
|
+
parent scroll/resize fast paths: they may compute dirty spans and child
|
|
854
|
+
heights from the previous table wrap. Keeping ``virtual_size`` current
|
|
855
|
+
before the next paint gives Textual the right geometry to invalidate.
|
|
856
|
+
"""
|
|
857
|
+
width = self.scrollable_content_region.width
|
|
858
|
+
if width <= 0 or not self._blocks or self._width_at_last_layout == width:
|
|
859
|
+
return
|
|
860
|
+
self._converge_layout()
|
|
861
|
+
self.refresh(layout=True)
|
|
862
|
+
|
|
863
|
+
def _find_block_at_line(self, line: int) -> tuple[int, _BlockLineInfo] | None:
|
|
864
|
+
"""Find which block contains a given virtual line using binary search."""
|
|
865
|
+
if not self._block_line_info:
|
|
866
|
+
return None
|
|
867
|
+
|
|
868
|
+
infos = self._block_line_info
|
|
869
|
+
lo, hi = 0, len(infos) - 1
|
|
870
|
+
while lo <= hi:
|
|
871
|
+
mid = (lo + hi) // 2
|
|
872
|
+
info = infos[mid]
|
|
873
|
+
if line < info.start_line:
|
|
874
|
+
hi = mid - 1
|
|
875
|
+
elif line >= info.start_line + info.height:
|
|
876
|
+
lo = mid + 1
|
|
877
|
+
else:
|
|
878
|
+
return mid, info
|
|
879
|
+
return None
|
|
880
|
+
|
|
881
|
+
def _render_block_line(self, block: MarkdownBlock, info: _BlockLineInfo, line: int, width: int) -> Strip:
|
|
882
|
+
"""Render a single virtual line from a block."""
|
|
883
|
+
local_line = line - info.start_line
|
|
884
|
+
|
|
885
|
+
base_style = self.visual_style
|
|
886
|
+
block_style = self._get_block_style_cached(info.block_index, block)
|
|
887
|
+
|
|
888
|
+
if local_line < info.top_margin:
|
|
889
|
+
return self._render_margin_line(info.block_index - 1, info.block_index, width)
|
|
890
|
+
|
|
891
|
+
content_end = info.top_margin + info.content_height
|
|
892
|
+
if local_line >= content_end:
|
|
893
|
+
return self._render_margin_line(info.block_index, info.block_index + 1, width)
|
|
894
|
+
|
|
895
|
+
content_line = local_line - info.top_margin
|
|
896
|
+
|
|
897
|
+
if content_line < block.padding_top:
|
|
898
|
+
return self._render_padding_line(block, base_style, block_style, width)
|
|
899
|
+
|
|
900
|
+
actual_content_end = info.content_height - block.padding_bottom
|
|
901
|
+
if content_line >= actual_content_end:
|
|
902
|
+
return self._render_padding_line(block, base_style, block_style, width)
|
|
903
|
+
|
|
904
|
+
actual_line = content_line - block.padding_top
|
|
905
|
+
|
|
906
|
+
border_width = len(block.border_left) if block.border_left else 0
|
|
907
|
+
# -1 reserves the right margin char appended at the end of this method
|
|
908
|
+
content_width = width - block.indent - block.padding_left - block.padding_right - border_width - 1
|
|
909
|
+
if content_width <= 0:
|
|
910
|
+
content_width = 1
|
|
911
|
+
|
|
912
|
+
if block.block_type == "hr":
|
|
913
|
+
strip = Strip([Segment("─" * content_width, block_style.rich_style)], content_width)
|
|
914
|
+
elif block.block_type == "table" and info.block_index in self._table_layouts:
|
|
915
|
+
cache_key = (info.block_index, actual_line)
|
|
916
|
+
strip = self._table_strips.get(cache_key)
|
|
917
|
+
if strip is None:
|
|
918
|
+
strip = self._render_table_line(block, info.block_index, actual_line, content_width, block_style)
|
|
919
|
+
self._table_strips[cache_key] = strip
|
|
920
|
+
elif block.block_type == "fence":
|
|
921
|
+
cache_key = (info.block_index, actual_line)
|
|
922
|
+
strip = self._fence_line_strips.get(cache_key)
|
|
923
|
+
if strip is None:
|
|
924
|
+
strip = self._render_fence_line(block, info.block_index, actual_line, content_width, block_style)
|
|
925
|
+
self._fence_line_strips[cache_key] = strip
|
|
926
|
+
elif block.block_type == "math" and info.block_index in self._math_rows:
|
|
927
|
+
rows = self._math_rows[info.block_index]
|
|
928
|
+
row = rows[actual_line] if actual_line < len(rows) else ""
|
|
929
|
+
strip = Strip([Segment(row, block_style.rich_style)], cell_len(row))
|
|
930
|
+
elif block.block_type == "diagram" and block.diagram is not None:
|
|
931
|
+
diagram_layout = self._diagram_layouts.get(info.block_index)
|
|
932
|
+
if diagram_layout is None:
|
|
933
|
+
strip = Strip.blank(content_width, block_style.rich_style)
|
|
934
|
+
elif actual_line < diagram_layout.preview_height:
|
|
935
|
+
row = block.diagram.crop_plain_row(actual_line, 0, content_width)
|
|
936
|
+
strip = Strip([Segment(row, block_style.rich_style)], content_width)
|
|
937
|
+
else:
|
|
938
|
+
action_text = render_str(widget_localizer(self), _DIAGRAM_OPEN.bind())
|
|
939
|
+
action_width = min(content_width, cell_len(action_text))
|
|
940
|
+
self._diagram_action_widths[line] = action_width
|
|
941
|
+
action_text = set_cell_size(action_text, action_width)
|
|
942
|
+
action_component_style = self._safe_component_style("virtualized-markdown--diagram-action").rich_style
|
|
943
|
+
action_style = RichStyle(
|
|
944
|
+
color=action_component_style.color,
|
|
945
|
+
bgcolor=block_style.rich_style.bgcolor,
|
|
946
|
+
underline=action_component_style.underline,
|
|
947
|
+
)
|
|
948
|
+
strip = Strip(
|
|
949
|
+
[
|
|
950
|
+
Segment(" " * (content_width - action_width), block_style.rich_style),
|
|
951
|
+
Segment(action_text, action_style),
|
|
952
|
+
],
|
|
953
|
+
content_width,
|
|
954
|
+
)
|
|
955
|
+
else:
|
|
956
|
+
strips = self._get_long_block_strips(block, info, content_width, block_style)
|
|
957
|
+
if strips is None:
|
|
958
|
+
strips = self._build_block_strips(block, content_width, block_style)
|
|
959
|
+
|
|
960
|
+
if actual_line < len(strips):
|
|
961
|
+
strip = strips[actual_line]
|
|
962
|
+
else:
|
|
963
|
+
strip = Strip.blank(content_width, block_style.rich_style)
|
|
964
|
+
|
|
965
|
+
if block.text_align == "center":
|
|
966
|
+
strip_len = strip.cell_length
|
|
967
|
+
if strip_len < content_width:
|
|
968
|
+
pad_left = (content_width - strip_len) // 2
|
|
969
|
+
pad_right = content_width - strip_len - pad_left
|
|
970
|
+
strip = Strip(
|
|
971
|
+
[
|
|
972
|
+
Segment(" " * pad_left, block_style.rich_style),
|
|
973
|
+
*strip._segments,
|
|
974
|
+
Segment(" " * pad_right, block_style.rich_style),
|
|
975
|
+
],
|
|
976
|
+
content_width,
|
|
977
|
+
)
|
|
978
|
+
|
|
979
|
+
left_offset = block.indent + block.padding_left + border_width
|
|
980
|
+
if left_offset > 0 or block.prefix:
|
|
981
|
+
segments = self._render_block_gutter(block, first_line=actual_line == 0)
|
|
982
|
+
if block.padding_left > 0:
|
|
983
|
+
segments.append(Segment(" " * block.padding_left, block_style.rich_style))
|
|
984
|
+
segments.extend(strip._segments)
|
|
985
|
+
if block.padding_right > 0:
|
|
986
|
+
segments.append(Segment(" " * block.padding_right, block_style.rich_style))
|
|
987
|
+
strip = Strip(segments)
|
|
988
|
+
|
|
989
|
+
pad_rich_style = block_style.rich_style
|
|
990
|
+
if pad_rich_style.underline or pad_rich_style.overline or pad_rich_style.strike:
|
|
991
|
+
pad_rich_style = RichStyle(
|
|
992
|
+
color=pad_rich_style.color,
|
|
993
|
+
bgcolor=pad_rich_style.bgcolor,
|
|
994
|
+
bold=pad_rich_style.bold,
|
|
995
|
+
dim=pad_rich_style.dim,
|
|
996
|
+
italic=pad_rich_style.italic,
|
|
997
|
+
)
|
|
998
|
+
if width > 1:
|
|
999
|
+
strip = strip.adjust_cell_length(width - 1, pad_rich_style)
|
|
1000
|
+
strip = Strip(
|
|
1001
|
+
[*strip._segments, Segment(" ", base_style.rich_style)],
|
|
1002
|
+
width,
|
|
1003
|
+
)
|
|
1004
|
+
else:
|
|
1005
|
+
strip = strip.extend_cell_length(width, pad_rich_style)
|
|
1006
|
+
|
|
1007
|
+
return strip
|
|
1008
|
+
|
|
1009
|
+
def _safe_component_style(self, *names: str) -> Style:
|
|
1010
|
+
"""Get a visual style, falling back to base style if component styles aren't ready.
|
|
1011
|
+
|
|
1012
|
+
During CSS reapplication (e.g. on focus change), ``_component_styles``
|
|
1013
|
+
is briefly cleared before being repopulated. If a render is triggered
|
|
1014
|
+
in that window, ``get_visual_style`` raises ``KeyError``. This helper
|
|
1015
|
+
catches that and returns the widget's base visual style instead.
|
|
1016
|
+
"""
|
|
1017
|
+
try:
|
|
1018
|
+
return self.get_visual_style(*names)
|
|
1019
|
+
except KeyError:
|
|
1020
|
+
return self.visual_style
|
|
1021
|
+
|
|
1022
|
+
def _get_block_style(self, block: MarkdownBlock) -> Style:
|
|
1023
|
+
"""Get the visual style for a block."""
|
|
1024
|
+
if block.bq_depth > 0 and block.block_type == "paragraph":
|
|
1025
|
+
return self._get_bq_depth_style(block.bq_depth)
|
|
1026
|
+
style = self._safe_component_style(block.style_name) if block.style_name else self.visual_style
|
|
1027
|
+
if block.bq_depth > 0 and block.block_type not in {"diagram", "fence", "math"}:
|
|
1028
|
+
style = replace(style, background=self._get_bq_depth_style(block.bq_depth).background)
|
|
1029
|
+
return style
|
|
1030
|
+
|
|
1031
|
+
def _get_block_style_cached(self, block_index: int, block: MarkdownBlock) -> Style:
|
|
1032
|
+
"""Memoized :meth:`_get_block_style` for use in the per-row hot path.
|
|
1033
|
+
|
|
1034
|
+
Cache is keyed by block index and invalidated wherever the layout or
|
|
1035
|
+
component-style resolution can change (``_layout_blocks``,
|
|
1036
|
+
``notify_style_update``, ``update``, ``append``)."""
|
|
1037
|
+
cached = self._block_style_cache.get(block_index)
|
|
1038
|
+
if cached is not None:
|
|
1039
|
+
return cached
|
|
1040
|
+
style = self._get_block_style(block)
|
|
1041
|
+
self._block_style_cache[block_index] = style
|
|
1042
|
+
return style
|
|
1043
|
+
|
|
1044
|
+
def _get_bq_depth_style(self, depth: int) -> Style:
|
|
1045
|
+
"""Compute a Style with a compounding background for blockquote depth."""
|
|
1046
|
+
base_style = self._safe_component_style("virtualized-markdown--block-quote")
|
|
1047
|
+
base_bg = base_style.background or self.visual_style.background
|
|
1048
|
+
if base_bg is None:
|
|
1049
|
+
return base_style
|
|
1050
|
+
|
|
1051
|
+
contrast = base_bg.get_contrast_text(1.0)
|
|
1052
|
+
blended = base_bg
|
|
1053
|
+
boost_factor = 0.04
|
|
1054
|
+
for _ in range(depth):
|
|
1055
|
+
blended = blended.blend(contrast, boost_factor, alpha=1.0)
|
|
1056
|
+
|
|
1057
|
+
return replace(base_style, background=blended)
|
|
1058
|
+
|
|
1059
|
+
def _render_gutter(self, gutter: tuple[MarkdownGutter, ...], *, first_line: bool = False) -> list[Segment]:
|
|
1060
|
+
"""Paint containers in source order, keeping list markers inside their quotes."""
|
|
1061
|
+
bq_border_style = self._safe_component_style("virtualized-markdown--block-quote-border")
|
|
1062
|
+
segments: list[Segment] = []
|
|
1063
|
+
depth = 0
|
|
1064
|
+
style = self.visual_style.rich_style
|
|
1065
|
+
for part in gutter:
|
|
1066
|
+
if part.kind == "quote":
|
|
1067
|
+
depth += 1
|
|
1068
|
+
style = self._get_bq_depth_style(depth).rich_style
|
|
1069
|
+
segments.append(Segment("▌", RichStyle(color=bq_border_style.rich_style.color, bgcolor=style.bgcolor)))
|
|
1070
|
+
segments.append(Segment(" ", style))
|
|
1071
|
+
else:
|
|
1072
|
+
prefix = part.prefix if first_line else ""
|
|
1073
|
+
segments.append(Segment(" " * (part.width - cell_len(prefix)), style))
|
|
1074
|
+
if prefix:
|
|
1075
|
+
bullet_style = self._safe_component_style("virtualized-markdown--bullet").rich_style
|
|
1076
|
+
segments.append(Segment(prefix, bullet_style + RichStyle(bgcolor=style.bgcolor)))
|
|
1077
|
+
return segments
|
|
1078
|
+
|
|
1079
|
+
def _render_block_gutter(self, block: MarkdownBlock, *, first_line: bool = False) -> list[Segment]:
|
|
1080
|
+
if block.gutter:
|
|
1081
|
+
return self._render_gutter(block.gutter, first_line=first_line)
|
|
1082
|
+
# External token handlers may still provide the flat block attributes.
|
|
1083
|
+
prefix = block.prefix if first_line else ""
|
|
1084
|
+
segments = [Segment(" " * max(0, block.indent - cell_len(prefix)), self.visual_style.rich_style)]
|
|
1085
|
+
if prefix:
|
|
1086
|
+
segments.append(Segment(prefix, self._safe_component_style("virtualized-markdown--bullet").rich_style))
|
|
1087
|
+
if block.border_left:
|
|
1088
|
+
segments.append(Segment(block.border_left, self._get_block_style(block).rich_style))
|
|
1089
|
+
return segments
|
|
1090
|
+
|
|
1091
|
+
def _render_margin_line(self, previous: int, following: int, width: int) -> Strip:
|
|
1092
|
+
"""Keep only shared container borders across the gap between two blocks."""
|
|
1093
|
+
shared: list[MarkdownGutter] = []
|
|
1094
|
+
if previous >= 0 and following < len(self._blocks):
|
|
1095
|
+
for left, right in zip(self._blocks[previous].gutter, self._blocks[following].gutter, strict=False):
|
|
1096
|
+
if left.container_id != right.container_id:
|
|
1097
|
+
break
|
|
1098
|
+
shared.append(left)
|
|
1099
|
+
depth = sum(part.kind == "quote" for part in shared)
|
|
1100
|
+
style = self._get_bq_depth_style(depth) if depth else self.visual_style
|
|
1101
|
+
strip = Strip(self._render_gutter(tuple(shared)))
|
|
1102
|
+
strip = strip.adjust_cell_length(max(0, width - 1), style.rich_style)
|
|
1103
|
+
return Strip([*strip._segments, Segment(" ", self.visual_style.rich_style)], width)
|
|
1104
|
+
|
|
1105
|
+
def _render_padding_line(self, block: MarkdownBlock, base_style: Style, block_style: Style, width: int) -> Strip:
|
|
1106
|
+
"""Render a padding line (e.g. top/bottom padding of a code fence)."""
|
|
1107
|
+
border_width = len(block.border_left) if block.border_left else 0
|
|
1108
|
+
left_offset = block.indent + block.padding_left + border_width
|
|
1109
|
+
if left_offset > 0:
|
|
1110
|
+
segments = self._render_block_gutter(block)
|
|
1111
|
+
if block.padding_left > 0:
|
|
1112
|
+
segments.append(Segment(" " * block.padding_left, block_style.rich_style))
|
|
1113
|
+
remaining = width - left_offset
|
|
1114
|
+
if block.block_type in {"diagram", "fence"} and remaining > 1:
|
|
1115
|
+
segments.append(Segment(" " * (remaining - 1), block_style.rich_style))
|
|
1116
|
+
segments.append(Segment(" ", base_style.rich_style))
|
|
1117
|
+
elif remaining > 0:
|
|
1118
|
+
segments.append(Segment(" " * remaining, block_style.rich_style))
|
|
1119
|
+
return Strip(segments, width)
|
|
1120
|
+
if block.block_type in {"diagram", "fence"} and width > 1:
|
|
1121
|
+
return Strip(
|
|
1122
|
+
[
|
|
1123
|
+
Segment(" " * (width - 1), block_style.rich_style),
|
|
1124
|
+
Segment(" ", base_style.rich_style),
|
|
1125
|
+
],
|
|
1126
|
+
width,
|
|
1127
|
+
)
|
|
1128
|
+
return Strip.blank(width, block_style.rich_style)
|
|
1129
|
+
|
|
1130
|
+
def _build_table_layout(self, block: MarkdownBlock, content_width: int) -> _TableLayout:
|
|
1131
|
+
"""Build table width/height metadata without rendering every row."""
|
|
1132
|
+
headers = block.table_headers or []
|
|
1133
|
+
rows = block.table_rows or []
|
|
1134
|
+
if not headers:
|
|
1135
|
+
return _TableLayout([], 0, [], [], 0)
|
|
1136
|
+
|
|
1137
|
+
col_count = len(headers)
|
|
1138
|
+
cell_pad = 1
|
|
1139
|
+
|
|
1140
|
+
overhead = col_count * (2 * cell_pad + 1) + 1
|
|
1141
|
+
available = content_width - overhead
|
|
1142
|
+
available = max(available, col_count)
|
|
1143
|
+
|
|
1144
|
+
nat_widths: list[int] = []
|
|
1145
|
+
min_widths: list[int] = []
|
|
1146
|
+
# A column holding any unbreakable 2-cell cluster (CJK glyph, emoji
|
|
1147
|
+
# sequence, etc.) needs at least 2 cells when space allows. ASCII-only
|
|
1148
|
+
# columns may shrink to 1 because they can wrap between characters.
|
|
1149
|
+
for header in headers:
|
|
1150
|
+
nat_widths.append(max(_cell_natural_width(header.plain), 1))
|
|
1151
|
+
min_widths.append(_cell_min_width(header.plain))
|
|
1152
|
+
for row in rows:
|
|
1153
|
+
for i, cell_content in enumerate(row):
|
|
1154
|
+
if i < col_count:
|
|
1155
|
+
nat_widths[i] = max(nat_widths[i], _cell_natural_width(cell_content.plain))
|
|
1156
|
+
min_widths[i] = max(min_widths[i], _cell_min_width(cell_content.plain))
|
|
1157
|
+
|
|
1158
|
+
total_nat = sum(nat_widths)
|
|
1159
|
+
if total_nat <= available:
|
|
1160
|
+
col_widths = nat_widths[:]
|
|
1161
|
+
else:
|
|
1162
|
+
# If the double-width floors can't all fit the content area, drop
|
|
1163
|
+
# them to 1 so the table still fits exactly (no right-edge crop that
|
|
1164
|
+
# would clip borders/data); cell rendering then truncates a glyph too
|
|
1165
|
+
# wide for its 1-cell column. ``available >= col_count`` (clamped
|
|
1166
|
+
# above), so a floor of 1 per column always fits.
|
|
1167
|
+
if sum(min_widths) > available:
|
|
1168
|
+
min_widths = [1] * col_count
|
|
1169
|
+
col_widths = _shrink_widest_columns(nat_widths, min_widths, available)
|
|
1170
|
+
|
|
1171
|
+
header_height = max(
|
|
1172
|
+
(_cell_wrapped_height(header.plain, col_widths[i]) for i, header in enumerate(headers)), default=1
|
|
1173
|
+
)
|
|
1174
|
+
|
|
1175
|
+
row_heights: list[int] = []
|
|
1176
|
+
for row in rows:
|
|
1177
|
+
height = 1
|
|
1178
|
+
for i in range(col_count):
|
|
1179
|
+
if i < len(row):
|
|
1180
|
+
height = max(height, _cell_wrapped_height(row[i].plain, col_widths[i]))
|
|
1181
|
+
row_heights.append(height)
|
|
1182
|
+
|
|
1183
|
+
row_starts: list[int] = []
|
|
1184
|
+
current_line = 2 + header_height
|
|
1185
|
+
for row_index, row_height in enumerate(row_heights):
|
|
1186
|
+
row_starts.append(current_line)
|
|
1187
|
+
current_line += row_height
|
|
1188
|
+
if row_index < len(row_heights) - 1:
|
|
1189
|
+
current_line += 1
|
|
1190
|
+
|
|
1191
|
+
# top border + header + header/body separator + body rows + body separators + bottom border
|
|
1192
|
+
total_height = current_line + 1
|
|
1193
|
+
return _TableLayout(
|
|
1194
|
+
col_widths=col_widths,
|
|
1195
|
+
header_height=header_height,
|
|
1196
|
+
row_heights=row_heights,
|
|
1197
|
+
row_starts=row_starts,
|
|
1198
|
+
total_height=total_height,
|
|
1199
|
+
)
|
|
1200
|
+
|
|
1201
|
+
def _table_border_style(self, block_style: Style) -> RichStyle:
|
|
1202
|
+
return RichStyle(
|
|
1203
|
+
color=block_style.rich_style.color,
|
|
1204
|
+
bgcolor=block_style.rich_style.bgcolor,
|
|
1205
|
+
dim=True,
|
|
1206
|
+
)
|
|
1207
|
+
|
|
1208
|
+
def _render_table_border(
|
|
1209
|
+
self,
|
|
1210
|
+
layout: _TableLayout,
|
|
1211
|
+
*,
|
|
1212
|
+
left: str,
|
|
1213
|
+
mid: str,
|
|
1214
|
+
right: str,
|
|
1215
|
+
border_rs: RichStyle,
|
|
1216
|
+
) -> Strip:
|
|
1217
|
+
parts: list[str] = [left]
|
|
1218
|
+
for i, width in enumerate(layout.col_widths):
|
|
1219
|
+
parts.append("\u2500" * (width + 2))
|
|
1220
|
+
if i < len(layout.col_widths) - 1:
|
|
1221
|
+
parts.append(mid)
|
|
1222
|
+
parts.append(right)
|
|
1223
|
+
return Strip([Segment("".join(parts), border_rs)])
|
|
1224
|
+
|
|
1225
|
+
def _render_table_data_line(
|
|
1226
|
+
self,
|
|
1227
|
+
layout: _TableLayout,
|
|
1228
|
+
cells: list[Content],
|
|
1229
|
+
line_idx: int,
|
|
1230
|
+
*,
|
|
1231
|
+
text_style: Style,
|
|
1232
|
+
cell_rs: RichStyle,
|
|
1233
|
+
border_rs: RichStyle,
|
|
1234
|
+
) -> Strip:
|
|
1235
|
+
segs: list[Segment] = [Segment("\u2502", border_rs)]
|
|
1236
|
+
for col_idx, col_width in enumerate(layout.col_widths):
|
|
1237
|
+
line_content = Content("")
|
|
1238
|
+
if col_idx < len(cells):
|
|
1239
|
+
content = cells[col_idx]
|
|
1240
|
+
cell_lines = _wrap_cell_text(content.plain, col_width)
|
|
1241
|
+
if line_idx < len(cell_lines):
|
|
1242
|
+
start = 0
|
|
1243
|
+
for previous_line in cell_lines[:line_idx]:
|
|
1244
|
+
start += len(previous_line)
|
|
1245
|
+
if content.plain[start : start + 1] == "\n":
|
|
1246
|
+
start += 1
|
|
1247
|
+
end = start + _cell_prefix_length(cell_lines[line_idx], col_width)
|
|
1248
|
+
line_content = _content_slice(content, start, end)
|
|
1249
|
+
segs.append(Segment(" ", cell_rs))
|
|
1250
|
+
segs.extend(
|
|
1251
|
+
Segment(text, style.rich_style)
|
|
1252
|
+
for text, style in line_content.render(text_style, end="", parse_style=self._get_style)
|
|
1253
|
+
if text
|
|
1254
|
+
)
|
|
1255
|
+
padding = col_width - cell_len(line_content.plain)
|
|
1256
|
+
if padding:
|
|
1257
|
+
# Padding and separators must not inherit a cell's final link.
|
|
1258
|
+
segs.append(Segment(" " * padding, text_style.rich_style))
|
|
1259
|
+
segs.append(Segment(" ", cell_rs))
|
|
1260
|
+
segs.append(Segment("\u2502", border_rs))
|
|
1261
|
+
return Strip(segs)
|
|
1262
|
+
|
|
1263
|
+
def _render_table_line(
|
|
1264
|
+
self,
|
|
1265
|
+
block: MarkdownBlock,
|
|
1266
|
+
block_index: int,
|
|
1267
|
+
actual_line: int,
|
|
1268
|
+
content_width: int,
|
|
1269
|
+
block_style: Style,
|
|
1270
|
+
) -> Strip:
|
|
1271
|
+
"""Render a single content line from a table block."""
|
|
1272
|
+
layout = self._table_layouts.get(block_index)
|
|
1273
|
+
headers = block.table_headers or []
|
|
1274
|
+
rows = block.table_rows or []
|
|
1275
|
+
if layout is None or not headers:
|
|
1276
|
+
return Strip.blank(content_width, block_style.rich_style)
|
|
1277
|
+
|
|
1278
|
+
header_style = self._safe_component_style("virtualized-markdown--table-header")
|
|
1279
|
+
if block.bq_depth:
|
|
1280
|
+
header_style = replace(header_style, background=block_style.background)
|
|
1281
|
+
cell_rs = block_style.rich_style
|
|
1282
|
+
border_rs = self._table_border_style(block_style)
|
|
1283
|
+
|
|
1284
|
+
if actual_line == 0:
|
|
1285
|
+
return self._render_table_border(layout, left="\u250c", mid="\u252c", right="\u2510", border_rs=border_rs)
|
|
1286
|
+
|
|
1287
|
+
header_end = 1 + layout.header_height
|
|
1288
|
+
if actual_line < header_end:
|
|
1289
|
+
return self._render_table_data_line(
|
|
1290
|
+
layout,
|
|
1291
|
+
headers,
|
|
1292
|
+
actual_line - 1,
|
|
1293
|
+
text_style=header_style,
|
|
1294
|
+
cell_rs=cell_rs,
|
|
1295
|
+
border_rs=border_rs,
|
|
1296
|
+
)
|
|
1297
|
+
|
|
1298
|
+
if actual_line == header_end:
|
|
1299
|
+
return self._render_table_border(layout, left="\u251c", mid="\u253c", right="\u2524", border_rs=border_rs)
|
|
1300
|
+
|
|
1301
|
+
if actual_line == layout.total_height - 1:
|
|
1302
|
+
return self._render_table_border(layout, left="\u2514", mid="\u2534", right="\u2518", border_rs=border_rs)
|
|
1303
|
+
|
|
1304
|
+
if not layout.row_starts:
|
|
1305
|
+
return Strip.blank(content_width, block_style.rich_style)
|
|
1306
|
+
|
|
1307
|
+
row_index = bisect_right(layout.row_starts, actual_line) - 1
|
|
1308
|
+
if row_index < 0 or row_index >= len(rows):
|
|
1309
|
+
return Strip.blank(content_width, block_style.rich_style)
|
|
1310
|
+
|
|
1311
|
+
row_start = layout.row_starts[row_index]
|
|
1312
|
+
row_height = layout.row_heights[row_index]
|
|
1313
|
+
row_line = actual_line - row_start
|
|
1314
|
+
if row_line < row_height:
|
|
1315
|
+
return self._render_table_data_line(
|
|
1316
|
+
layout,
|
|
1317
|
+
rows[row_index],
|
|
1318
|
+
row_line,
|
|
1319
|
+
text_style=block_style,
|
|
1320
|
+
cell_rs=cell_rs,
|
|
1321
|
+
border_rs=border_rs,
|
|
1322
|
+
)
|
|
1323
|
+
|
|
1324
|
+
return self._render_table_border(layout, left="\u251c", mid="\u253c", right="\u2524", border_rs=border_rs)
|
|
1325
|
+
|
|
1326
|
+
def _build_table_strips(self, block: MarkdownBlock, content_width: int) -> list[Strip]:
|
|
1327
|
+
"""Build every strip for a table block.
|
|
1328
|
+
|
|
1329
|
+
This compatibility helper is intentionally not used by layout; table
|
|
1330
|
+
rows are rendered lazily through ``_render_table_line``. It is kept so
|
|
1331
|
+
profiler instrumentation and any external/debug callers that still look
|
|
1332
|
+
up ``_build_table_strips`` by name continue to work.
|
|
1333
|
+
"""
|
|
1334
|
+
layout = self._build_table_layout(block, content_width)
|
|
1335
|
+
if layout.total_height <= 0:
|
|
1336
|
+
return []
|
|
1337
|
+
block_index = -1
|
|
1338
|
+
self._table_layouts[block_index] = layout
|
|
1339
|
+
block_style = self._get_block_style(block)
|
|
1340
|
+
try:
|
|
1341
|
+
return [
|
|
1342
|
+
self._render_table_line(block, block_index, actual_line, content_width, block_style)
|
|
1343
|
+
for actual_line in range(layout.total_height)
|
|
1344
|
+
]
|
|
1345
|
+
finally:
|
|
1346
|
+
self._table_layouts.pop(block_index, None)
|
|
1347
|
+
|
|
1348
|
+
def _build_fence_layout(self, block: MarkdownBlock, content_width: int, render_rules: Any) -> _FenceLayout:
|
|
1349
|
+
"""Build source-line heights for a fence without rendering styled strips."""
|
|
1350
|
+
text = block.content.plain
|
|
1351
|
+
line_ranges = _line_ranges(text)
|
|
1352
|
+
|
|
1353
|
+
line_starts: list[int] = []
|
|
1354
|
+
current_line = 0
|
|
1355
|
+
# ``Content.get_height`` wraps each line at the content width minus
|
|
1356
|
+
# both line pads and splits only past it, so a tab-free line whose
|
|
1357
|
+
# cells fit is exactly one row. Every character is at most two cells
|
|
1358
|
+
# wide, which lets short lines skip even the cell count; only tabbed
|
|
1359
|
+
# or possibly overlong lines pay for building Content and wrapping it.
|
|
1360
|
+
wrap_width = content_width - render_rules.get("line_pad", 0) * 2
|
|
1361
|
+
for start, end in line_ranges:
|
|
1362
|
+
line_starts.append(current_line)
|
|
1363
|
+
source = text[start:end]
|
|
1364
|
+
if "\t" not in source and (2 * (end - start) <= wrap_width or cell_len(source) <= wrap_width):
|
|
1365
|
+
current_line += 1
|
|
1366
|
+
continue
|
|
1367
|
+
line = Content(source, strip_control_codes=False)
|
|
1368
|
+
height = max(1, line.get_height(render_rules, content_width))
|
|
1369
|
+
current_line += height
|
|
1370
|
+
|
|
1371
|
+
return _FenceLayout(
|
|
1372
|
+
line_ranges=line_ranges,
|
|
1373
|
+
line_span_indices=_span_indices_by_line(block.content.spans, line_ranges),
|
|
1374
|
+
line_starts=line_starts,
|
|
1375
|
+
total_height=current_line,
|
|
1376
|
+
)
|
|
1377
|
+
|
|
1378
|
+
def _render_fence_line(
|
|
1379
|
+
self,
|
|
1380
|
+
block: MarkdownBlock,
|
|
1381
|
+
block_index: int,
|
|
1382
|
+
actual_line: int,
|
|
1383
|
+
content_width: int,
|
|
1384
|
+
block_style: Style,
|
|
1385
|
+
) -> Strip:
|
|
1386
|
+
"""Render one wrapped content line from a fenced code block."""
|
|
1387
|
+
layout = self._fence_layouts.get(block_index)
|
|
1388
|
+
if layout is None:
|
|
1389
|
+
layout = self._build_fence_layout(block, content_width, self.styles.get_render_rules())
|
|
1390
|
+
self._fence_layouts[block_index] = layout
|
|
1391
|
+
if actual_line < 0 or actual_line >= layout.total_height or not layout.line_starts:
|
|
1392
|
+
return Strip.blank(content_width, block_style.rich_style)
|
|
1393
|
+
|
|
1394
|
+
source_line_index = bisect_right(layout.line_starts, actual_line) - 1
|
|
1395
|
+
if source_line_index < 0 or source_line_index >= len(layout.line_ranges):
|
|
1396
|
+
return Strip.blank(content_width, block_style.rich_style)
|
|
1397
|
+
|
|
1398
|
+
source_cache_key = (block_index, source_line_index)
|
|
1399
|
+
source_strips = self._fence_source_line_strips.get(source_cache_key)
|
|
1400
|
+
if source_strips is None:
|
|
1401
|
+
source_start, source_end = layout.line_ranges[source_line_index]
|
|
1402
|
+
source_line = _content_slice(
|
|
1403
|
+
block.content,
|
|
1404
|
+
source_start,
|
|
1405
|
+
source_end,
|
|
1406
|
+
None if layout.line_span_indices is None else layout.line_span_indices[source_line_index],
|
|
1407
|
+
)
|
|
1408
|
+
render_options = RenderOptions(self._get_style, self.styles.get_render_rules())
|
|
1409
|
+
source_strips = source_line.render_strips(
|
|
1410
|
+
content_width,
|
|
1411
|
+
None,
|
|
1412
|
+
block_style,
|
|
1413
|
+
render_options,
|
|
1414
|
+
)
|
|
1415
|
+
self._fence_source_line_strips[source_cache_key] = source_strips
|
|
1416
|
+
|
|
1417
|
+
source_line_offset = actual_line - layout.line_starts[source_line_index]
|
|
1418
|
+
if source_line_offset < len(source_strips):
|
|
1419
|
+
return source_strips[source_line_offset]
|
|
1420
|
+
return Strip.blank(content_width, block_style.rich_style)
|
|
1421
|
+
|
|
1422
|
+
def _visible_height_threshold(self) -> int:
|
|
1423
|
+
"""Return the current viewport-sized threshold for block-strip caching."""
|
|
1424
|
+
if self._frame_visible_height > 0:
|
|
1425
|
+
return self._frame_visible_height
|
|
1426
|
+
if self.is_attached:
|
|
1427
|
+
return max(1, self.screen.size.height)
|
|
1428
|
+
return 40
|
|
1429
|
+
|
|
1430
|
+
def _get_long_block_strips(
|
|
1431
|
+
self,
|
|
1432
|
+
block: MarkdownBlock,
|
|
1433
|
+
info: _BlockLineInfo,
|
|
1434
|
+
content_width: int,
|
|
1435
|
+
block_style: Style,
|
|
1436
|
+
) -> list[Strip] | None:
|
|
1437
|
+
"""Return cached strips for blocks large enough to exceed the visible crop."""
|
|
1438
|
+
if info.content_height <= self._visible_height_threshold():
|
|
1439
|
+
return None
|
|
1440
|
+
|
|
1441
|
+
strips = self._block_strips.get(info.block_index)
|
|
1442
|
+
if strips is None:
|
|
1443
|
+
strips = self._build_block_strips(block, content_width, block_style)
|
|
1444
|
+
self._block_strips[info.block_index] = strips
|
|
1445
|
+
return strips
|
|
1446
|
+
|
|
1447
|
+
def _build_block_strips(self, block: MarkdownBlock, content_width: int, block_style: Style) -> list[Strip]:
|
|
1448
|
+
"""Render all strips for a non-table/fence markdown block."""
|
|
1449
|
+
render_options = RenderOptions(self._get_style, self.styles.get_render_rules())
|
|
1450
|
+
return block.content.render_strips(content_width, None, block_style, render_options)
|
|
1451
|
+
|
|
1452
|
+
def _build_fence_strips(self, block: MarkdownBlock, content_width: int, block_style: Style) -> list[Strip]:
|
|
1453
|
+
"""Render and cache every strip of a fenced code block.
|
|
1454
|
+
|
|
1455
|
+
Compatibility helper for profilers/debug callers that still ask for
|
|
1456
|
+
the whole fence at once. Normal scrolling uses ``_render_fence_line``
|
|
1457
|
+
so a long offscreen fence doesn't block the UI thread when its first
|
|
1458
|
+
row becomes visible.
|
|
1459
|
+
"""
|
|
1460
|
+
render_options = RenderOptions(self._get_style, self.styles.get_render_rules())
|
|
1461
|
+
return block.content.render_strips(content_width, None, block_style, render_options)
|
|
1462
|
+
|
|
1463
|
+
def render_line(self, y: int) -> Strip:
|
|
1464
|
+
"""Render a line of content for the Line API."""
|
|
1465
|
+
if not self.is_attached:
|
|
1466
|
+
return Strip.blank(max(0, self.size.width)).apply_offsets(0, y)
|
|
1467
|
+
|
|
1468
|
+
self._converge_layout()
|
|
1469
|
+
width = self.scrollable_content_region.width
|
|
1470
|
+
if width <= 0:
|
|
1471
|
+
return Strip.blank(0)
|
|
1472
|
+
|
|
1473
|
+
line_number = round(self.scroll_offset.y) + y
|
|
1474
|
+
|
|
1475
|
+
cache_key = (line_number, width)
|
|
1476
|
+
cached = self._line_cache.get(cache_key)
|
|
1477
|
+
if cached is not None:
|
|
1478
|
+
strip = cached
|
|
1479
|
+
elif line_number >= self._total_lines or line_number < 0:
|
|
1480
|
+
strip = Strip.blank(width, self.visual_style.rich_style)
|
|
1481
|
+
self._line_cache[cache_key] = strip
|
|
1482
|
+
else:
|
|
1483
|
+
result = self._find_block_at_line(line_number)
|
|
1484
|
+
if result is None:
|
|
1485
|
+
strip = Strip.blank(width, self.visual_style.rich_style)
|
|
1486
|
+
else:
|
|
1487
|
+
_block_idx, info = result
|
|
1488
|
+
block = self._blocks[info.block_index]
|
|
1489
|
+
strip = self._render_block_line(block, info, line_number, width)
|
|
1490
|
+
self._line_cache[cache_key] = strip
|
|
1491
|
+
|
|
1492
|
+
# Apply selection highlight (post-cache so base strip stays clean)
|
|
1493
|
+
selection = self._safe_text_selection()
|
|
1494
|
+
if selection is not None:
|
|
1495
|
+
span = selection.get_span(line_number)
|
|
1496
|
+
if span is not None:
|
|
1497
|
+
strip = self._apply_selection_highlight(strip, span)
|
|
1498
|
+
|
|
1499
|
+
# Embed offset metadata for selection coordinate resolution after
|
|
1500
|
+
# highlighting. Highlighting splits segments, and same-row selection
|
|
1501
|
+
# hit-testing needs each split segment to carry its own row offset.
|
|
1502
|
+
strip = strip.apply_offsets(0, line_number)
|
|
1503
|
+
|
|
1504
|
+
return strip
|
|
1505
|
+
|
|
1506
|
+
def _safe_text_selection(self) -> Selection | None:
|
|
1507
|
+
try:
|
|
1508
|
+
return self.text_selection
|
|
1509
|
+
except NoScreen:
|
|
1510
|
+
return None
|
|
1511
|
+
|
|
1512
|
+
def _apply_selection_highlight(self, strip: Strip, span: tuple[int, int]) -> Strip:
|
|
1513
|
+
"""Apply selection highlight style to a strip.
|
|
1514
|
+
|
|
1515
|
+
Args:
|
|
1516
|
+
strip: The strip to highlight.
|
|
1517
|
+
span: Tuple of (start_x, end_x) character offsets. end_x=-1 means end of line.
|
|
1518
|
+
"""
|
|
1519
|
+
start_x, end_x = span
|
|
1520
|
+
selection_style = _normalize_selection_rich_style(self.screen.get_component_rich_style("screen--selection"))
|
|
1521
|
+
segments: list[Segment] = []
|
|
1522
|
+
pos = 0
|
|
1523
|
+
for seg in strip._segments:
|
|
1524
|
+
text, style, control = seg
|
|
1525
|
+
seg_start = pos
|
|
1526
|
+
seg_end = pos + len(text)
|
|
1527
|
+
actual_end = seg_end if end_x == -1 else min(seg_end, end_x)
|
|
1528
|
+
|
|
1529
|
+
if actual_end > seg_start and start_x < seg_end and seg_start < (seg_end if end_x == -1 else end_x):
|
|
1530
|
+
# This segment overlaps with selection
|
|
1531
|
+
sel_start = max(0, start_x - seg_start)
|
|
1532
|
+
sel_end = len(text) if end_x == -1 else min(len(text), end_x - seg_start)
|
|
1533
|
+
|
|
1534
|
+
if sel_start > 0:
|
|
1535
|
+
segments.append(Segment(text[:sel_start], style, control))
|
|
1536
|
+
combined = style + selection_style if style else selection_style
|
|
1537
|
+
segments.append(Segment(text[sel_start:sel_end], combined, control))
|
|
1538
|
+
if sel_end < len(text):
|
|
1539
|
+
segments.append(Segment(text[sel_end:], style, control))
|
|
1540
|
+
else:
|
|
1541
|
+
segments.append(seg)
|
|
1542
|
+
pos = seg_end
|
|
1543
|
+
return Strip(segments, strip.cell_length)
|
|
1544
|
+
|
|
1545
|
+
def notify_style_update(self) -> None:
|
|
1546
|
+
"""Clear caches when theme or styles change so strips are re-rendered."""
|
|
1547
|
+
self._line_cache.clear()
|
|
1548
|
+
self._table_layouts.clear()
|
|
1549
|
+
self._table_strips.clear()
|
|
1550
|
+
self._fence_layouts.clear()
|
|
1551
|
+
self._fence_line_strips.clear()
|
|
1552
|
+
self._fence_source_line_strips.clear()
|
|
1553
|
+
self._block_strips.clear()
|
|
1554
|
+
self._block_style_cache.clear()
|
|
1555
|
+
self._plain_selection_cache = None
|
|
1556
|
+
self._width_at_last_layout = 0 # force _layout_blocks on next render
|
|
1557
|
+
self._lines_cache_result = None
|
|
1558
|
+
super().notify_style_update()
|
|
1559
|
+
|
|
1560
|
+
def render_lines(self, crop: Region) -> list[Strip]:
|
|
1561
|
+
"""Render visible lines."""
|
|
1562
|
+
if not self.is_attached:
|
|
1563
|
+
return [Strip.blank(crop.width) for _ in crop.line_range]
|
|
1564
|
+
|
|
1565
|
+
self._converge_layout()
|
|
1566
|
+
|
|
1567
|
+
width = self.scrollable_content_region.width
|
|
1568
|
+
if width <= 0:
|
|
1569
|
+
return [Strip.blank(crop.width)] * crop.height
|
|
1570
|
+
|
|
1571
|
+
self._frame_scroll_y = round(self.scroll_offset.y)
|
|
1572
|
+
self._frame_width = width
|
|
1573
|
+
self._frame_visible_height = crop.height
|
|
1574
|
+
self._frame_selection = self._safe_text_selection()
|
|
1575
|
+
|
|
1576
|
+
if self._can_render_lines_direct(crop):
|
|
1577
|
+
hover_link_style = self._hover_link_style()
|
|
1578
|
+
lines_key = (
|
|
1579
|
+
self._frame_scroll_y,
|
|
1580
|
+
width,
|
|
1581
|
+
crop.x,
|
|
1582
|
+
crop.y,
|
|
1583
|
+
crop.width,
|
|
1584
|
+
crop.height,
|
|
1585
|
+
self.app.theme,
|
|
1586
|
+
str(self.app.console.color_system),
|
|
1587
|
+
)
|
|
1588
|
+
cacheable = self._frame_selection is None and hover_link_style is None
|
|
1589
|
+
if (
|
|
1590
|
+
cacheable
|
|
1591
|
+
and lines_key == self._lines_cache_key
|
|
1592
|
+
and self._lines_cache_result is not None
|
|
1593
|
+
and crop.height == len(self._lines_cache_result)
|
|
1594
|
+
):
|
|
1595
|
+
return self._lines_cache_result
|
|
1596
|
+
|
|
1597
|
+
result = self._render_lines_direct(crop, hover_link_style)
|
|
1598
|
+
if cacheable:
|
|
1599
|
+
self._lines_cache_key = lines_key
|
|
1600
|
+
self._lines_cache_result = result
|
|
1601
|
+
else:
|
|
1602
|
+
self._lines_cache_result = None
|
|
1603
|
+
return result
|
|
1604
|
+
|
|
1605
|
+
self._lines_cache_result = None
|
|
1606
|
+
return super().render_lines(crop)
|
|
1607
|
+
|
|
1608
|
+
def _can_render_lines_direct(self, crop: Region) -> bool:
|
|
1609
|
+
"""Return true when the chat markdown direct renderer preserves widget styling."""
|
|
1610
|
+
styles = self.styles
|
|
1611
|
+
if styles.gutter:
|
|
1612
|
+
return False
|
|
1613
|
+
if any(edge for edge, _color in styles.outline):
|
|
1614
|
+
return False
|
|
1615
|
+
if styles.has_rule("hatch") and styles.hatch != "none":
|
|
1616
|
+
return False
|
|
1617
|
+
if styles.tint.a:
|
|
1618
|
+
return False
|
|
1619
|
+
if styles.background_tint.a:
|
|
1620
|
+
return False
|
|
1621
|
+
if styles.text_opacity != 1.0 or self.opacity != 1.0:
|
|
1622
|
+
return False
|
|
1623
|
+
if styles.line_pad:
|
|
1624
|
+
return False
|
|
1625
|
+
if styles.text_align not in {"start", "left"}:
|
|
1626
|
+
return False
|
|
1627
|
+
if self.show_vertical_scrollbar or self.show_horizontal_scrollbar:
|
|
1628
|
+
return False
|
|
1629
|
+
return crop.width > 0 and self.content_region.width > 0
|
|
1630
|
+
|
|
1631
|
+
def _hover_link_style(self) -> RichStyle | None:
|
|
1632
|
+
"""Return link hover style only when Textual would apply it."""
|
|
1633
|
+
hover_style = self.hover_style
|
|
1634
|
+
# Textual exposes no public predicate for clickable hover links, so
|
|
1635
|
+
# mirror StylesCache.render_widget's internal metadata checks here.
|
|
1636
|
+
if not self.auto_links or not hover_style._link_id or not hover_style._meta:
|
|
1637
|
+
return None
|
|
1638
|
+
if "@click" not in hover_style.meta:
|
|
1639
|
+
return None
|
|
1640
|
+
return self.link_style_hover
|
|
1641
|
+
|
|
1642
|
+
def _render_lines_direct(self, crop: Region, hover_link_style: RichStyle | None) -> list[Strip]:
|
|
1643
|
+
"""Render clipped markdown rows without ``StylesCache.render``.
|
|
1644
|
+
|
|
1645
|
+
Chat message markdown has no padding, borders, filters, or internal
|
|
1646
|
+
scrollbars. In that case ``render_line`` already returns the final
|
|
1647
|
+
strip, and the generic style wrapper only adds per-row bookkeeping
|
|
1648
|
+
during parent chat scrolling.
|
|
1649
|
+
"""
|
|
1650
|
+
content_width = self.content_region.width
|
|
1651
|
+
rich_style = self.visual_style.rich_style
|
|
1652
|
+
line_filters = self.get_line_filters()
|
|
1653
|
+
_base_background, background = self.background_colors
|
|
1654
|
+
strips: list[Strip] = []
|
|
1655
|
+
for y in crop.line_range:
|
|
1656
|
+
strip = self.render_line(y)
|
|
1657
|
+
if strip.cell_length != content_width:
|
|
1658
|
+
strip = strip.adjust_cell_length(content_width, rich_style)
|
|
1659
|
+
for line_filter in line_filters:
|
|
1660
|
+
strip = strip.apply_filter(line_filter, background)
|
|
1661
|
+
if hover_link_style:
|
|
1662
|
+
strip = strip.style_links(self.hover_style.link_id, hover_link_style)
|
|
1663
|
+
if crop.column_span != (0, content_width):
|
|
1664
|
+
strip = strip.crop(crop.x, crop.x + crop.width)
|
|
1665
|
+
strips.append(strip)
|
|
1666
|
+
return strips
|
|
1667
|
+
|
|
1668
|
+
def get_content_width(self, container: Size, viewport: Size) -> int:
|
|
1669
|
+
return self.virtual_size.width
|
|
1670
|
+
|
|
1671
|
+
def get_content_height(self, container: Size, viewport: Size, width: int) -> int:
|
|
1672
|
+
if self._blocks and width > 0 and self._width_at_last_layout != width:
|
|
1673
|
+
self._layout_blocks(width)
|
|
1674
|
+
return self.virtual_size.height
|
|
1675
|
+
|
|
1676
|
+
def update(self, markdown: str) -> AwaitComplete:
|
|
1677
|
+
"""Update the document with new Markdown.
|
|
1678
|
+
|
|
1679
|
+
Updates coalesce: until the scheduled parse starts, further
|
|
1680
|
+
``update()``/``append()`` calls join it, and it parses the source as it
|
|
1681
|
+
stands when it starts. A burst of cumulative streamed updates therefore
|
|
1682
|
+
costs one parse and one layout, not one per call.
|
|
1683
|
+
|
|
1684
|
+
Args:
|
|
1685
|
+
markdown: A string containing Markdown.
|
|
1686
|
+
|
|
1687
|
+
Returns:
|
|
1688
|
+
An optionally awaitable object that resolves once this markdown, or
|
|
1689
|
+
newer, is installed. Cancelling one awaiter leaves the shared parse
|
|
1690
|
+
running for the others.
|
|
1691
|
+
"""
|
|
1692
|
+
self._markdown = markdown
|
|
1693
|
+
return self._request_parse(announce_contents=True)
|
|
1694
|
+
|
|
1695
|
+
def append(self, markdown: str) -> AwaitComplete:
|
|
1696
|
+
"""Append markdown to the document.
|
|
1697
|
+
|
|
1698
|
+
Coalesces with pending updates as ``update()`` does.
|
|
1699
|
+
|
|
1700
|
+
Args:
|
|
1701
|
+
markdown: A fragment of markdown to be appended.
|
|
1702
|
+
|
|
1703
|
+
Returns:
|
|
1704
|
+
An optionally awaitable object that resolves once the document with
|
|
1705
|
+
this fragment, or newer, is installed.
|
|
1706
|
+
"""
|
|
1707
|
+
self._markdown = self.source + markdown
|
|
1708
|
+
return self._request_parse(announce_contents=False)
|
|
1709
|
+
|
|
1710
|
+
def _request_parse(self, *, announce_contents: bool) -> AwaitComplete:
|
|
1711
|
+
"""Join the pending parse of ``_markdown``, or schedule one.
|
|
1712
|
+
|
|
1713
|
+
``TableOfContentsUpdated`` follows the parse when any joined request
|
|
1714
|
+
set ``announce_contents`` (every ``update()`` does) or when the parsed
|
|
1715
|
+
document has headings, so a group of ``append()`` calls announces only
|
|
1716
|
+
what one of them alone would have.
|
|
1717
|
+
"""
|
|
1718
|
+
self._table_of_contents = None
|
|
1719
|
+
pending = self._pending_parse
|
|
1720
|
+
# A pending task is done here only when it was cancelled before it read
|
|
1721
|
+
# the source (loop shutdown); it can no longer parse, so it is not joinable.
|
|
1722
|
+
if pending is not None and not pending.task.done():
|
|
1723
|
+
pending.announce_contents = pending.announce_contents or announce_contents
|
|
1724
|
+
return AwaitComplete(asyncio.shield(pending.task))
|
|
1725
|
+
|
|
1726
|
+
# Drop the old document's render caches once per parse; installing the
|
|
1727
|
+
# new blocks clears them again.
|
|
1728
|
+
self._line_cache.clear()
|
|
1729
|
+
self._table_layouts.clear()
|
|
1730
|
+
self._table_strips.clear()
|
|
1731
|
+
self._fence_layouts.clear()
|
|
1732
|
+
self._fence_line_strips.clear()
|
|
1733
|
+
self._fence_source_line_strips.clear()
|
|
1734
|
+
self._block_strips.clear()
|
|
1735
|
+
self._block_style_cache.clear()
|
|
1736
|
+
self._plain_selection_cache = None
|
|
1737
|
+
self._lines_cache_result = None
|
|
1738
|
+
pending = _PendingParse(self._run_parse, announce_contents=announce_contents)
|
|
1739
|
+
self._pending_parse = pending
|
|
1740
|
+
return AwaitComplete(asyncio.shield(pending.task))
|
|
1741
|
+
|
|
1742
|
+
async def _run_parse(self, request: _PendingParse) -> None:
|
|
1743
|
+
"""Parse and install the current source once the lock is free."""
|
|
1744
|
+
async with self.lock:
|
|
1745
|
+
# Reading the source ends the join window: a later request is
|
|
1746
|
+
# newer than this parse's text and schedules a parse of its own.
|
|
1747
|
+
if self._pending_parse is request:
|
|
1748
|
+
self._pending_parse = None
|
|
1749
|
+
markdown = self._markdown
|
|
1750
|
+
blocks = await asyncio.get_running_loop().run_in_executor(None, self._build_blocks, markdown)
|
|
1751
|
+
self._install_blocks(blocks)
|
|
1752
|
+
|
|
1753
|
+
lines = markdown.splitlines()
|
|
1754
|
+
self._last_parsed_line = len(lines) - (1 if lines and lines[-1] else 0)
|
|
1755
|
+
self.refresh()
|
|
1756
|
+
if request.announce_contents or any(block.block_type == "heading" for block in blocks):
|
|
1757
|
+
self.post_message(
|
|
1758
|
+
VirtualizedMarkdown.TableOfContentsUpdated(self, self.table_of_contents).set_sender(self)
|
|
1759
|
+
)
|
|
1760
|
+
|
|
1761
|
+
def _install_blocks(self, blocks: list[MarkdownBlock]) -> None:
|
|
1762
|
+
"""Commit freshly parsed blocks and lay them out.
|
|
1763
|
+
|
|
1764
|
+
The heading list is dropped here as well as when the update starts: an
|
|
1765
|
+
earlier update still holding the lock rebuilds it from its own blocks
|
|
1766
|
+
after this update's start cleared it, and ``_layout_blocks`` does not
|
|
1767
|
+
touch it.
|
|
1768
|
+
|
|
1769
|
+
A widget that has never been arranged (just mounted, or mounted inside
|
|
1770
|
+
a hidden container) has no content width yet. Laying out now would use
|
|
1771
|
+
the placeholder width that the first arrange immediately lays out
|
|
1772
|
+
again, so the layout waits for the real width instead:
|
|
1773
|
+
``get_content_height`` supplies it on the first arrange, and
|
|
1774
|
+
``on_resize`` and ``render_line`` converge on it otherwise. Those skip
|
|
1775
|
+
a widget without blocks, so an empty document, zero lines at any
|
|
1776
|
+
width, is laid out at once.
|
|
1777
|
+
"""
|
|
1778
|
+
self._blocks = blocks
|
|
1779
|
+
self._table_of_contents = None
|
|
1780
|
+
if blocks and self.scrollable_content_region.width <= 0:
|
|
1781
|
+
self._defer_layout_until_arranged()
|
|
1782
|
+
return
|
|
1783
|
+
self._layout_blocks()
|
|
1784
|
+
|
|
1785
|
+
def _defer_layout_until_arranged(self) -> None:
|
|
1786
|
+
"""Drop the layout of replaced blocks and request a measuring pass.
|
|
1787
|
+
|
|
1788
|
+
Everything cleared here indexes blocks by position, so none of it may
|
|
1789
|
+
outlive the blocks it was computed from. ``virtual_size`` keeps its
|
|
1790
|
+
last value (the constructor's line estimate for a fresh widget), so
|
|
1791
|
+
the parent never measures a zero-height transcript row meanwhile.
|
|
1792
|
+
"""
|
|
1793
|
+
self._width_at_last_layout = 0
|
|
1794
|
+
self._total_lines = 0
|
|
1795
|
+
self._block_line_info.clear()
|
|
1796
|
+
self._diagram_action_lines.clear()
|
|
1797
|
+
self._diagram_action_widths.clear()
|
|
1798
|
+
self._plain_selection_cache = None
|
|
1799
|
+
self._lines_cache_result = None
|
|
1800
|
+
self.refresh(layout=True)
|
|
1801
|
+
|
|
1802
|
+
def scroll_to_block_id(self, block_id: str) -> None:
|
|
1803
|
+
"""Scroll to a block by its ID.
|
|
1804
|
+
|
|
1805
|
+
Args:
|
|
1806
|
+
block_id: The block ID to scroll to.
|
|
1807
|
+
"""
|
|
1808
|
+
for info in self._block_line_info:
|
|
1809
|
+
block = self._blocks[info.block_index]
|
|
1810
|
+
if block.block_id == block_id:
|
|
1811
|
+
self.scroll_to(y=info.start_line, animate=False)
|
|
1812
|
+
return
|
|
1813
|
+
|
|
1814
|
+
def selection_updated(self, selection: Selection | None) -> None:
|
|
1815
|
+
"""Refresh display when selection changes (highlight is post-cache)."""
|
|
1816
|
+
self._plain_selection_cache = None
|
|
1817
|
+
self._lines_cache_result = None
|
|
1818
|
+
self.refresh()
|
|
1819
|
+
|
|
1820
|
+
def _build_plain_selection_lines(self) -> tuple[list[str], list[tuple[str, int]]]:
|
|
1821
|
+
"""Build plain text rows and copy separators from rendered strips.
|
|
1822
|
+
|
|
1823
|
+
Renders every virtual line (using cache where available) and
|
|
1824
|
+
extracts plain text. This ensures indentation, prefixes, borders,
|
|
1825
|
+
padding, and word-wrapping match the screen exactly.
|
|
1826
|
+
|
|
1827
|
+
The companion separator list records how adjacent rendered rows should
|
|
1828
|
+
be joined when copied. Textual selection is visual-row based, so a
|
|
1829
|
+
soft-wrapped paragraph row would otherwise become a hard ``\n`` in the
|
|
1830
|
+
clipboard. Markdown source line ends still copy as newlines.
|
|
1831
|
+
"""
|
|
1832
|
+
width = self._width_at_last_layout
|
|
1833
|
+
if width <= 0 or self._total_lines == 0:
|
|
1834
|
+
return [], []
|
|
1835
|
+
|
|
1836
|
+
lines: list[str] = []
|
|
1837
|
+
formatted_cache: dict[int, list[Any]] = {}
|
|
1838
|
+
for y in range(self._total_lines):
|
|
1839
|
+
cache_key = (y, width)
|
|
1840
|
+
cached = self._line_cache.get(cache_key)
|
|
1841
|
+
if cached is None:
|
|
1842
|
+
result = self._find_block_at_line(y)
|
|
1843
|
+
if result is None:
|
|
1844
|
+
cached = Strip.blank(width, self.visual_style.rich_style)
|
|
1845
|
+
else:
|
|
1846
|
+
_, info = result
|
|
1847
|
+
block = self._blocks[info.block_index]
|
|
1848
|
+
cached = self._render_block_line(block, info, y, width)
|
|
1849
|
+
self._line_cache[cache_key] = cached
|
|
1850
|
+
lines.append(_strip_plain_text(cached).rstrip())
|
|
1851
|
+
separators = [
|
|
1852
|
+
self._selection_separator_after_line(y, width, formatted_cache, lines[y + 1])
|
|
1853
|
+
for y in range(self._total_lines - 1)
|
|
1854
|
+
]
|
|
1855
|
+
return lines, separators
|
|
1856
|
+
|
|
1857
|
+
def _selection_separator_after_line(
|
|
1858
|
+
self,
|
|
1859
|
+
line: int,
|
|
1860
|
+
width: int,
|
|
1861
|
+
formatted_cache: dict[int, list[Any]],
|
|
1862
|
+
following_visual_line: str,
|
|
1863
|
+
) -> tuple[str, int]:
|
|
1864
|
+
"""Return the clipboard separator and next-row prefix skip after a rendered virtual line."""
|
|
1865
|
+
current = self._find_block_at_line(line)
|
|
1866
|
+
following = self._find_block_at_line(line + 1)
|
|
1867
|
+
if current is None or following is None:
|
|
1868
|
+
return "\n", 0
|
|
1869
|
+
|
|
1870
|
+
_current_index, current_info = current
|
|
1871
|
+
_following_index, following_info = following
|
|
1872
|
+
if current_info.block_index != following_info.block_index:
|
|
1873
|
+
return "\n", 0
|
|
1874
|
+
|
|
1875
|
+
block = self._blocks[current_info.block_index]
|
|
1876
|
+
if block.block_type in {"diagram", "hr", "table"} or current_info.block_index in self._math_rows:
|
|
1877
|
+
return "\n", 0
|
|
1878
|
+
|
|
1879
|
+
current_content_line = self._actual_content_line_index(block, current_info, line)
|
|
1880
|
+
following_content_line = self._actual_content_line_index(block, following_info, line + 1)
|
|
1881
|
+
if current_content_line is None or following_content_line is None:
|
|
1882
|
+
return "\n", 0
|
|
1883
|
+
|
|
1884
|
+
formatted_lines = self._formatted_lines_for_selection(block, current_info, width, formatted_cache)
|
|
1885
|
+
if current_content_line >= len(formatted_lines) or following_content_line >= len(formatted_lines):
|
|
1886
|
+
return "\n", 0
|
|
1887
|
+
|
|
1888
|
+
current_formatted = formatted_lines[current_content_line]
|
|
1889
|
+
following_formatted = formatted_lines[following_content_line]
|
|
1890
|
+
if current_formatted.line_end or current_formatted.y != following_formatted.y:
|
|
1891
|
+
return "\n", 0
|
|
1892
|
+
separator = self._soft_wrap_separator(block, current_formatted, following_formatted)
|
|
1893
|
+
return separator, self._soft_wrap_prefix_skip(following_formatted, following_visual_line)
|
|
1894
|
+
|
|
1895
|
+
def _actual_content_line_index(
|
|
1896
|
+
self,
|
|
1897
|
+
block: MarkdownBlock,
|
|
1898
|
+
info: _BlockLineInfo,
|
|
1899
|
+
line: int,
|
|
1900
|
+
) -> int | None:
|
|
1901
|
+
"""Map a virtual line to the block content line, excluding margins/padding."""
|
|
1902
|
+
local_line = line - info.start_line
|
|
1903
|
+
if local_line < info.top_margin:
|
|
1904
|
+
return None
|
|
1905
|
+
|
|
1906
|
+
content_end = info.top_margin + info.content_height
|
|
1907
|
+
if local_line >= content_end:
|
|
1908
|
+
return None
|
|
1909
|
+
|
|
1910
|
+
content_line = local_line - info.top_margin
|
|
1911
|
+
if content_line < block.padding_top:
|
|
1912
|
+
return None
|
|
1913
|
+
|
|
1914
|
+
actual_content_end = info.content_height - block.padding_bottom
|
|
1915
|
+
if content_line >= actual_content_end:
|
|
1916
|
+
return None
|
|
1917
|
+
|
|
1918
|
+
return content_line - block.padding_top
|
|
1919
|
+
|
|
1920
|
+
def _formatted_lines_for_selection(
|
|
1921
|
+
self,
|
|
1922
|
+
block: MarkdownBlock,
|
|
1923
|
+
info: _BlockLineInfo,
|
|
1924
|
+
width: int,
|
|
1925
|
+
formatted_cache: dict[int, list[Any]],
|
|
1926
|
+
) -> list[Any]:
|
|
1927
|
+
"""Return Textual formatted content lines for source-line boundary checks."""
|
|
1928
|
+
cached = formatted_cache.get(info.block_index)
|
|
1929
|
+
if cached is not None:
|
|
1930
|
+
return cached
|
|
1931
|
+
|
|
1932
|
+
content_width = self._content_width_for_block(block, width)
|
|
1933
|
+
rules = self.styles.get_render_rules()
|
|
1934
|
+
get_rule = rules.get
|
|
1935
|
+
lines = block.content._wrap_and_format(
|
|
1936
|
+
content_width,
|
|
1937
|
+
align=get_rule("text_align", "left"),
|
|
1938
|
+
overflow=get_rule("text_overflow", "fold"),
|
|
1939
|
+
no_wrap=get_rule("text_wrap", "wrap") == "nowrap",
|
|
1940
|
+
line_pad=get_rule("line_pad", 0),
|
|
1941
|
+
tab_size=8,
|
|
1942
|
+
get_style=self._get_style,
|
|
1943
|
+
)
|
|
1944
|
+
formatted_cache[info.block_index] = lines
|
|
1945
|
+
return lines
|
|
1946
|
+
|
|
1947
|
+
def _content_width_for_block(self, block: MarkdownBlock, width: int) -> int:
|
|
1948
|
+
"""Return the content cell width used by ``_render_block_line``."""
|
|
1949
|
+
border_width = len(block.border_left) if block.border_left else 0
|
|
1950
|
+
content_width = width - block.indent - block.padding_left - block.padding_right - border_width - 1
|
|
1951
|
+
return max(1, content_width)
|
|
1952
|
+
|
|
1953
|
+
@staticmethod
|
|
1954
|
+
def _soft_wrap_separator(block: MarkdownBlock, current_line: Any, following_line: Any) -> str:
|
|
1955
|
+
"""Return source whitespace removed between two visual wrap rows."""
|
|
1956
|
+
source_lines = block.content.plain.split("\n")
|
|
1957
|
+
source_line_index = current_line.y
|
|
1958
|
+
if source_line_index < 0 or source_line_index >= len(source_lines):
|
|
1959
|
+
return " "
|
|
1960
|
+
|
|
1961
|
+
source = source_lines[source_line_index]
|
|
1962
|
+
source_offsets = current_line.source_offsets
|
|
1963
|
+
if source_offsets is not None:
|
|
1964
|
+
rendered_end = min(len(current_line.plain), len(source_offsets) - 1)
|
|
1965
|
+
current_source_end = source_offsets[rendered_end]
|
|
1966
|
+
else:
|
|
1967
|
+
current_source_end = current_line.x + len(current_line.plain)
|
|
1968
|
+
following_source_offsets = following_line.source_offsets
|
|
1969
|
+
following_source_start = (
|
|
1970
|
+
following_source_offsets[0] if following_source_offsets is not None else following_line.x
|
|
1971
|
+
)
|
|
1972
|
+
return source[current_source_end:following_source_start]
|
|
1973
|
+
|
|
1974
|
+
@staticmethod
|
|
1975
|
+
def _soft_wrap_prefix_skip(following_line: Any, following_visual_line: str) -> int:
|
|
1976
|
+
"""Return visual-only prefix length to drop from a soft-wrapped continuation row."""
|
|
1977
|
+
content_text = following_line.plain.rstrip()
|
|
1978
|
+
if not content_text or not following_visual_line.endswith(content_text):
|
|
1979
|
+
return 0
|
|
1980
|
+
return len(following_visual_line) - len(content_text)
|
|
1981
|
+
|
|
1982
|
+
def _extract_selection_from_plain_lines(
|
|
1983
|
+
self,
|
|
1984
|
+
selection: Selection,
|
|
1985
|
+
lines: list[str],
|
|
1986
|
+
separators: list[tuple[str, int]],
|
|
1987
|
+
) -> str:
|
|
1988
|
+
"""Extract selected text while honoring per-row clipboard separators."""
|
|
1989
|
+
if not lines:
|
|
1990
|
+
return ""
|
|
1991
|
+
|
|
1992
|
+
if selection.start is None:
|
|
1993
|
+
start_line_index = 0
|
|
1994
|
+
start_offset = 0
|
|
1995
|
+
else:
|
|
1996
|
+
start_line_index, start_offset = selection.start.transpose
|
|
1997
|
+
|
|
1998
|
+
if selection.end is None:
|
|
1999
|
+
end_line_index = len(lines) - 1
|
|
2000
|
+
end_offset = len(lines[-1])
|
|
2001
|
+
else:
|
|
2002
|
+
end_line_index, end_offset = selection.end.transpose
|
|
2003
|
+
|
|
2004
|
+
if start_line_index < 0 or start_line_index >= len(lines):
|
|
2005
|
+
return ""
|
|
2006
|
+
end_line_index = min(len(lines) - 1, end_line_index)
|
|
2007
|
+
|
|
2008
|
+
if start_line_index == end_line_index:
|
|
2009
|
+
return lines[start_line_index][start_offset:end_offset]
|
|
2010
|
+
|
|
2011
|
+
chunks: list[str] = []
|
|
2012
|
+
for line_index in range(start_line_index, end_line_index + 1):
|
|
2013
|
+
line = lines[line_index]
|
|
2014
|
+
start = start_offset if line_index == start_line_index else 0
|
|
2015
|
+
end = end_offset if line_index == end_line_index else len(line)
|
|
2016
|
+
if line_index > start_line_index:
|
|
2017
|
+
_separator, prefix_skip = separators[line_index - 1]
|
|
2018
|
+
if start == 0:
|
|
2019
|
+
start = prefix_skip
|
|
2020
|
+
end = max(start, end)
|
|
2021
|
+
chunks.append(line[start:end])
|
|
2022
|
+
if line_index < end_line_index:
|
|
2023
|
+
separator, _prefix_skip = separators[line_index]
|
|
2024
|
+
chunks.append(separator)
|
|
2025
|
+
return "".join(chunks)
|
|
2026
|
+
|
|
2027
|
+
def get_selection(self, selection: Selection) -> tuple[str, str] | None:
|
|
2028
|
+
"""Extract selected text from rendered strips.
|
|
2029
|
+
|
|
2030
|
+
Builds plain text from the actual rendered strips so that character
|
|
2031
|
+
positions (indentation, prefixes, borders, wrapping) match the
|
|
2032
|
+
screen layout exactly.
|
|
2033
|
+
"""
|
|
2034
|
+
if self._total_lines == 0:
|
|
2035
|
+
return None
|
|
2036
|
+
if self._plain_selection_cache is None:
|
|
2037
|
+
self._plain_selection_cache = self._build_plain_selection_lines()
|
|
2038
|
+
lines, separators = self._plain_selection_cache
|
|
2039
|
+
extracted = self._extract_selection_from_plain_lines(selection, lines, separators)
|
|
2040
|
+
if not extracted:
|
|
2041
|
+
return None
|
|
2042
|
+
return extracted, "\n"
|
|
2043
|
+
|
|
2044
|
+
async def action_link(self, href: str) -> None:
|
|
2045
|
+
"""Called on link click."""
|
|
2046
|
+
self.post_message(VirtualizedMarkdown.LinkClicked(self, href))
|