mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-17 12:56:41 +02:00
Compare commits
67 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 85805d2c38 | |||
| 982cb3e71a | |||
| 294d0e7ee3 | |||
| 8be1ca836c | |||
| 2e5f96fa41 | |||
| c056b0add9 | |||
| b00bb3c083 | |||
| d9befd3aa6 | |||
| 49de703ba1 | |||
| 22988894c8 | |||
| 34b1754f25 | |||
| 54fe3504ba | |||
| d2c862e32d | |||
| afc53afb35 | |||
| b56e49c5dc | |||
| 8b2a909e1f | |||
| e9c954d45e | |||
| 6f449d13af | |||
| 6e375bef0d | |||
| 67106a6967 | |||
| b5d690620d | |||
| 9db3ce1d0b | |||
| 1cc55b68ef | |||
| 469f667774 | |||
| 6603d9a9f0 | |||
| 5dc1920a4c | |||
| d3e5f3f3a8 | |||
| ce4cb820f7 | |||
| ba5be6b625 | |||
| f95c3f4177 | |||
| d2b1307bff | |||
| b40ba32adc | |||
| ce0cebb7d7 | |||
| f478f89a68 | |||
| 85d95f0f2b | |||
| 1515efc77c | |||
| 6d393759e1 | |||
| a1701678cd | |||
| c411a26d6f | |||
| 85dbfeb314 | |||
| 085c0e4e2b | |||
| 8404a97c3e | |||
| 0ee3b1ede2 | |||
| a826936702 | |||
| fd4a5d5a63 | |||
| 69cf1d7b7e | |||
| 8e0a1d1167 | |||
| f22021187d | |||
| febecc348a | |||
| c5ccfc3e94 | |||
| 1f6efc6b94 | |||
| 727fe6f942 | |||
| a91e79382e | |||
| 5c626e0a2f | |||
| 8e9e383219 | |||
| f383008cc1 | |||
| 303ade25ed | |||
| 53f8e7850e | |||
| ca8ce88354 | |||
| 37a86439c4 | |||
| 269b43f4de | |||
| 3f25e5bf86 | |||
| 67765fa47c | |||
| 58b1c58bc5 | |||
| d80badc50f | |||
| 75279e5ccf | |||
| 7893b84614 |
@@ -9,7 +9,7 @@
|
||||
</p>
|
||||
<p align="center">AI coding agent, built for the terminal.</p>
|
||||
<p align="center">
|
||||
<a href="https://discord.gg/opencode"><img alt="Discord" src="https://img.shields.io/discord/1391832426048651334?style=flat-square&label=discord" /></a>
|
||||
<a href="https://opencode.ai/discord"><img alt="Discord" src="https://img.shields.io/discord/1391832426048651334?style=flat-square&label=discord" /></a>
|
||||
<a href="https://www.npmjs.com/package/opencode-ai"><img alt="npm" src="https://img.shields.io/npm/v/opencode-ai?style=flat-square" /></a>
|
||||
<a href="https://github.com/sst/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/sst/opencode/publish.yml?style=flat-square&branch=dev" /></a>
|
||||
</p>
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
# Download Stats
|
||||
|
||||
| Date | GitHub Downloads | npm Downloads | Total |
|
||||
| ---------- | ---------------- | --------------- | ---------------- |
|
||||
| 2025-06-29 | 18,789 (+0) | 39,420 (+0) | 58,209 (+0) |
|
||||
| 2025-06-30 | 20,127 (+1,338) | 41,059 (+1,639) | 61,186 (+2,977) |
|
||||
| 2025-07-01 | 22,108 (+1,981) | 43,745 (+2,686) | 65,853 (+4,667) |
|
||||
| 2025-07-02 | 24,814 (+2,706) | 46,168 (+2,423) | 70,982 (+5,129) |
|
||||
| 2025-07-03 | 27,834 (+3,020) | 49,955 (+3,787) | 77,789 (+6,807) |
|
||||
| 2025-07-04 | 30,608 (+2,774) | 54,758 (+4,803) | 85,366 (+7,577) |
|
||||
| 2025-07-05 | 32,524 (+1,916) | 58,371 (+3,613) | 90,895 (+5,529) |
|
||||
| 2025-07-06 | 33,766 (+1,242) | 59,694 (+1,323) | 93,460 (+2,565) |
|
||||
| 2025-07-08 | 38,052 (+4,286) | 64,468 (+4,774) | 102,520 (+9,060) |
|
||||
| Date | GitHub Downloads | npm Downloads | Total |
|
||||
| ---------- | ---------------- | --------------- | ----------------- |
|
||||
| 2025-06-29 | 18,789 (+0) | 39,420 (+0) | 58,209 (+0) |
|
||||
| 2025-06-30 | 20,127 (+1,338) | 41,059 (+1,639) | 61,186 (+2,977) |
|
||||
| 2025-07-01 | 22,108 (+1,981) | 43,745 (+2,686) | 65,853 (+4,667) |
|
||||
| 2025-07-02 | 24,814 (+2,706) | 46,168 (+2,423) | 70,982 (+5,129) |
|
||||
| 2025-07-03 | 27,834 (+3,020) | 49,955 (+3,787) | 77,789 (+6,807) |
|
||||
| 2025-07-04 | 30,608 (+2,774) | 54,758 (+4,803) | 85,366 (+7,577) |
|
||||
| 2025-07-05 | 32,524 (+1,916) | 58,371 (+3,613) | 90,895 (+5,529) |
|
||||
| 2025-07-06 | 33,766 (+1,242) | 59,694 (+1,323) | 93,460 (+2,565) |
|
||||
| 2025-07-08 | 38,052 (+4,286) | 64,468 (+4,774) | 102,520 (+9,060) |
|
||||
| 2025-07-10 | 43,796 (+5,744) | 71,402 (+6,934) | 115,198 (+12,678) |
|
||||
|
||||
@@ -83,7 +83,7 @@
|
||||
"sharp": "0.32.5",
|
||||
"shiki": "3.4.2",
|
||||
"solid-js": "1.9.7",
|
||||
"toolbeam-docs-theme": "0.4.1",
|
||||
"toolbeam-docs-theme": "0.4.3",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "catalog:",
|
||||
@@ -1531,7 +1531,7 @@
|
||||
|
||||
"token-types": ["token-types@6.0.0", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-lbDrTLVsHhOMljPscd0yitpozq7Ga2M5Cvez5AjGg8GASBjtt6iERCAJ93yommPmz62fb45oFIXHEZ3u9bfJEA=="],
|
||||
|
||||
"toolbeam-docs-theme": ["toolbeam-docs-theme@0.4.1", "", { "peerDependencies": { "@astrojs/starlight": "^0.34.3", "astro": "^5.7.13" } }, "sha512-lTI4dHZaVNQky29m7sb36Oy4tWPwxsCuFxFjF8hgGW0vpV+S6qPvI9SwsJFvdE/OHO5DoI7VMbryV1pxZHkkHQ=="],
|
||||
"toolbeam-docs-theme": ["toolbeam-docs-theme@0.4.3", "", { "peerDependencies": { "@astrojs/starlight": "^0.34.3", "astro": "^5.7.13" } }, "sha512-3um/NsSq4xFeKbKrNGPHIzfTixwnEVvroqA8Q+lecnYHHJ5TtiYTggHDqewOW+I67t0J1IVBwVKUPjxiQfIcog=="],
|
||||
|
||||
"tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
|
||||
|
||||
|
||||
@@ -1,546 +0,0 @@
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"$schema": {
|
||||
"type": "string",
|
||||
"description": "JSON schema reference for configuration validation"
|
||||
},
|
||||
"theme": {
|
||||
"type": "string",
|
||||
"description": "Theme name to use for the interface"
|
||||
},
|
||||
"keybinds": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"leader": {
|
||||
"type": "string",
|
||||
"default": "ctrl+x",
|
||||
"description": "Leader key for keybind combinations\n\ndefault: `ctrl+x`",
|
||||
"examples": [
|
||||
"ctrl+x"
|
||||
]
|
||||
},
|
||||
"app_help": {
|
||||
"type": "string",
|
||||
"default": "<leader>h",
|
||||
"description": "Show help dialog\n\ndefault: `<leader>h`",
|
||||
"examples": [
|
||||
"<leader>h"
|
||||
]
|
||||
},
|
||||
"editor_open": {
|
||||
"type": "string",
|
||||
"default": "<leader>e",
|
||||
"description": "Open external editor\n\ndefault: `<leader>e`",
|
||||
"examples": [
|
||||
"<leader>e"
|
||||
]
|
||||
},
|
||||
"session_new": {
|
||||
"type": "string",
|
||||
"default": "<leader>n",
|
||||
"description": "Create a new session\n\ndefault: `<leader>n`",
|
||||
"examples": [
|
||||
"<leader>n"
|
||||
]
|
||||
},
|
||||
"session_list": {
|
||||
"type": "string",
|
||||
"default": "<leader>l",
|
||||
"description": "List all sessions\n\ndefault: `<leader>l`",
|
||||
"examples": [
|
||||
"<leader>l"
|
||||
]
|
||||
},
|
||||
"session_share": {
|
||||
"type": "string",
|
||||
"default": "<leader>s",
|
||||
"description": "Share current session\n\ndefault: `<leader>s`",
|
||||
"examples": [
|
||||
"<leader>s"
|
||||
]
|
||||
},
|
||||
"session_unshare": {
|
||||
"type": "string",
|
||||
"default": "<leader>u",
|
||||
"description": "Unshare current session\n\ndefault: `<leader>u`",
|
||||
"examples": [
|
||||
"<leader>u"
|
||||
]
|
||||
},
|
||||
"session_interrupt": {
|
||||
"type": "string",
|
||||
"default": "esc",
|
||||
"description": "Interrupt current session\n\ndefault: `esc`",
|
||||
"examples": [
|
||||
"esc"
|
||||
]
|
||||
},
|
||||
"session_compact": {
|
||||
"type": "string",
|
||||
"default": "<leader>c",
|
||||
"description": "Compact the session\n\ndefault: `<leader>c`",
|
||||
"examples": [
|
||||
"<leader>c"
|
||||
]
|
||||
},
|
||||
"tool_details": {
|
||||
"type": "string",
|
||||
"default": "<leader>d",
|
||||
"description": "Toggle tool details\n\ndefault: `<leader>d`",
|
||||
"examples": [
|
||||
"<leader>d"
|
||||
]
|
||||
},
|
||||
"model_list": {
|
||||
"type": "string",
|
||||
"default": "<leader>m",
|
||||
"description": "List available models\n\ndefault: `<leader>m`",
|
||||
"examples": [
|
||||
"<leader>m"
|
||||
]
|
||||
},
|
||||
"theme_list": {
|
||||
"type": "string",
|
||||
"default": "<leader>t",
|
||||
"description": "List available themes\n\ndefault: `<leader>t`",
|
||||
"examples": [
|
||||
"<leader>t"
|
||||
]
|
||||
},
|
||||
"file_list": {
|
||||
"type": "string",
|
||||
"default": "<leader>f",
|
||||
"description": "List files\n\ndefault: `<leader>f`",
|
||||
"examples": [
|
||||
"<leader>f"
|
||||
]
|
||||
},
|
||||
"file_close": {
|
||||
"type": "string",
|
||||
"default": "esc",
|
||||
"description": "Close file\n\ndefault: `esc`",
|
||||
"examples": [
|
||||
"esc"
|
||||
]
|
||||
},
|
||||
"file_search": {
|
||||
"type": "string",
|
||||
"default": "<leader>/",
|
||||
"description": "Search file\n\ndefault: `<leader>/`",
|
||||
"examples": [
|
||||
"<leader>/"
|
||||
]
|
||||
},
|
||||
"file_diff_toggle": {
|
||||
"type": "string",
|
||||
"default": "<leader>v",
|
||||
"description": "Split/unified diff\n\ndefault: `<leader>v`",
|
||||
"examples": [
|
||||
"<leader>v"
|
||||
]
|
||||
},
|
||||
"project_init": {
|
||||
"type": "string",
|
||||
"default": "<leader>i",
|
||||
"description": "Create/update AGENTS.md\n\ndefault: `<leader>i`",
|
||||
"examples": [
|
||||
"<leader>i"
|
||||
]
|
||||
},
|
||||
"input_clear": {
|
||||
"type": "string",
|
||||
"default": "ctrl+c",
|
||||
"description": "Clear input field\n\ndefault: `ctrl+c`",
|
||||
"examples": [
|
||||
"ctrl+c"
|
||||
]
|
||||
},
|
||||
"input_paste": {
|
||||
"type": "string",
|
||||
"default": "ctrl+v",
|
||||
"description": "Paste from clipboard\n\ndefault: `ctrl+v`",
|
||||
"examples": [
|
||||
"ctrl+v"
|
||||
]
|
||||
},
|
||||
"input_submit": {
|
||||
"type": "string",
|
||||
"default": "enter",
|
||||
"description": "Submit input\n\ndefault: `enter`",
|
||||
"examples": [
|
||||
"enter"
|
||||
]
|
||||
},
|
||||
"input_newline": {
|
||||
"type": "string",
|
||||
"default": "shift+enter,ctrl+j",
|
||||
"description": "Insert newline in input\n\ndefault: `shift+enter,ctrl+j`",
|
||||
"examples": [
|
||||
"shift+enter,ctrl+j"
|
||||
]
|
||||
},
|
||||
"messages_page_up": {
|
||||
"type": "string",
|
||||
"default": "pgup",
|
||||
"description": "Scroll messages up by one page\n\ndefault: `pgup`",
|
||||
"examples": [
|
||||
"pgup"
|
||||
]
|
||||
},
|
||||
"messages_page_down": {
|
||||
"type": "string",
|
||||
"default": "pgdown",
|
||||
"description": "Scroll messages down by one page\n\ndefault: `pgdown`",
|
||||
"examples": [
|
||||
"pgdown"
|
||||
]
|
||||
},
|
||||
"messages_half_page_up": {
|
||||
"type": "string",
|
||||
"default": "ctrl+alt+u",
|
||||
"description": "Scroll messages up by half page\n\ndefault: `ctrl+alt+u`",
|
||||
"examples": [
|
||||
"ctrl+alt+u"
|
||||
]
|
||||
},
|
||||
"messages_half_page_down": {
|
||||
"type": "string",
|
||||
"default": "ctrl+alt+d",
|
||||
"description": "Scroll messages down by half page\n\ndefault: `ctrl+alt+d`",
|
||||
"examples": [
|
||||
"ctrl+alt+d"
|
||||
]
|
||||
},
|
||||
"messages_previous": {
|
||||
"type": "string",
|
||||
"default": "ctrl+up",
|
||||
"description": "Navigate to previous message\n\ndefault: `ctrl+up`",
|
||||
"examples": [
|
||||
"ctrl+up"
|
||||
]
|
||||
},
|
||||
"messages_next": {
|
||||
"type": "string",
|
||||
"default": "ctrl+down",
|
||||
"description": "Navigate to next message\n\ndefault: `ctrl+down`",
|
||||
"examples": [
|
||||
"ctrl+down"
|
||||
]
|
||||
},
|
||||
"messages_first": {
|
||||
"type": "string",
|
||||
"default": "ctrl+g",
|
||||
"description": "Navigate to first message\n\ndefault: `ctrl+g`",
|
||||
"examples": [
|
||||
"ctrl+g"
|
||||
]
|
||||
},
|
||||
"messages_last": {
|
||||
"type": "string",
|
||||
"default": "ctrl+alt+g",
|
||||
"description": "Navigate to last message\n\ndefault: `ctrl+alt+g`",
|
||||
"examples": [
|
||||
"ctrl+alt+g"
|
||||
]
|
||||
},
|
||||
"messages_layout_toggle": {
|
||||
"type": "string",
|
||||
"default": "<leader>p",
|
||||
"description": "Toggle layout\n\ndefault: `<leader>p`",
|
||||
"examples": [
|
||||
"<leader>p"
|
||||
]
|
||||
},
|
||||
"messages_copy": {
|
||||
"type": "string",
|
||||
"default": "<leader>y",
|
||||
"description": "Copy message\n\ndefault: `<leader>y`",
|
||||
"examples": [
|
||||
"<leader>y"
|
||||
]
|
||||
},
|
||||
"messages_revert": {
|
||||
"type": "string",
|
||||
"default": "<leader>r",
|
||||
"description": "Revert message\n\ndefault: `<leader>r`",
|
||||
"examples": [
|
||||
"<leader>r"
|
||||
]
|
||||
},
|
||||
"app_exit": {
|
||||
"type": "string",
|
||||
"default": "ctrl+c,<leader>q",
|
||||
"description": "Exit the application\n\ndefault: `ctrl+c,<leader>q`",
|
||||
"examples": [
|
||||
"ctrl+c,<leader>q"
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"description": "Custom keybind configurations"
|
||||
},
|
||||
"autoshare": {
|
||||
"type": "boolean",
|
||||
"description": "Share newly created sessions automatically"
|
||||
},
|
||||
"autoupdate": {
|
||||
"type": "boolean",
|
||||
"description": "Automatically update to the latest version"
|
||||
},
|
||||
"disabled_providers": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Disable providers that are loaded automatically"
|
||||
},
|
||||
"model": {
|
||||
"type": "string",
|
||||
"description": "Model to use in the format of provider/model, eg anthropic/claude-2"
|
||||
},
|
||||
"provider": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"api": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"env": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"npm": {
|
||||
"type": "string"
|
||||
},
|
||||
"models": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"release_date": {
|
||||
"type": "string"
|
||||
},
|
||||
"attachment": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"reasoning": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"temperature": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"tool_call": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"cost": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"input": {
|
||||
"type": "number"
|
||||
},
|
||||
"output": {
|
||||
"type": "number"
|
||||
},
|
||||
"cache_read": {
|
||||
"type": "number"
|
||||
},
|
||||
"cache_write": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"input",
|
||||
"output"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"limit": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"context": {
|
||||
"type": "number"
|
||||
},
|
||||
"output": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"context",
|
||||
"output"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"options": {
|
||||
"type": "object",
|
||||
"additionalProperties": {}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"type": "object",
|
||||
"additionalProperties": {}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"models"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"description": "Custom provider configurations and model overrides"
|
||||
},
|
||||
"mcp": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "local",
|
||||
"description": "Type of MCP server connection"
|
||||
},
|
||||
"command": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Command and arguments to run the MCP server"
|
||||
},
|
||||
"environment": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Environment variables to set when running the MCP server"
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Enable or disable the MCP server on startup"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type",
|
||||
"command"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "remote",
|
||||
"description": "Type of MCP server connection"
|
||||
},
|
||||
"url": {
|
||||
"type": "string",
|
||||
"description": "URL of the remote MCP server"
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Enable or disable the MCP server on startup"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type",
|
||||
"url"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"description": "MCP (Model Context Protocol) server configurations"
|
||||
},
|
||||
"instructions": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Additional instruction files or patterns to include"
|
||||
},
|
||||
"experimental": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"hook": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"file_edited": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"command": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"environment": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"command"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"session_completed": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"command": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"environment": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"command"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"$schema": "http://json-schema.org/draft-07/schema#"
|
||||
}
|
||||
@@ -103,6 +103,7 @@ if (!snapshot) {
|
||||
const lower = x.toLowerCase()
|
||||
return (
|
||||
!lower.includes("ignore:") &&
|
||||
!lower.includes("chore:") &&
|
||||
!lower.includes("ci:") &&
|
||||
!lower.includes("wip:") &&
|
||||
!lower.includes("docs:") &&
|
||||
|
||||
@@ -4,6 +4,8 @@ import "zod-openapi/extend"
|
||||
import { Config } from "../src/config/config"
|
||||
import { zodToJsonSchema } from "zod-to-json-schema"
|
||||
|
||||
const file = process.argv[2]
|
||||
|
||||
const result = zodToJsonSchema(Config.Info, {
|
||||
/**
|
||||
* We'll use the `default` values of the field as the only value in `examples`.
|
||||
@@ -30,4 +32,4 @@ const result = zodToJsonSchema(Config.Info, {
|
||||
return jsonSchema
|
||||
},
|
||||
})
|
||||
await Bun.write("config.schema.json", JSON.stringify(result, null, 2))
|
||||
await Bun.write(file, JSON.stringify(result, null, 2))
|
||||
|
||||
@@ -3,6 +3,7 @@ import { cmd } from "../cmd"
|
||||
import { FileCommand } from "./file"
|
||||
import { LSPCommand } from "./lsp"
|
||||
import { RipgrepCommand } from "./ripgrep"
|
||||
import { ScrapCommand } from "./scrap"
|
||||
import { SnapshotCommand } from "./snapshot"
|
||||
|
||||
export const DebugCommand = cmd({
|
||||
@@ -12,6 +13,7 @@ export const DebugCommand = cmd({
|
||||
.command(LSPCommand)
|
||||
.command(RipgrepCommand)
|
||||
.command(FileCommand)
|
||||
.command(ScrapCommand)
|
||||
.command(SnapshotCommand)
|
||||
.command({
|
||||
command: "wait",
|
||||
|
||||
@@ -5,7 +5,8 @@ import { Log } from "../../../util/log"
|
||||
|
||||
export const LSPCommand = cmd({
|
||||
command: "lsp",
|
||||
builder: (yargs) => yargs.command(DiagnosticsCommand).command(SymbolsCommand).demandCommand(),
|
||||
builder: (yargs) =>
|
||||
yargs.command(DiagnosticsCommand).command(SymbolsCommand).command(DocumentSymbolsCommand).demandCommand(),
|
||||
async handler() {},
|
||||
})
|
||||
|
||||
@@ -31,3 +32,15 @@ export const SymbolsCommand = cmd({
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
export const DocumentSymbolsCommand = cmd({
|
||||
command: "document-symbols <uri>",
|
||||
builder: (yargs) => yargs.positional("uri", { type: "string", demandOption: true }),
|
||||
async handler(args) {
|
||||
await bootstrap({ cwd: process.cwd() }, async () => {
|
||||
using _ = Log.Default.time("document-symbols")
|
||||
const results = await LSP.documentSymbol(args.uri)
|
||||
console.log(JSON.stringify(results, null, 2))
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { cmd } from "../cmd"
|
||||
|
||||
export const ScrapCommand = cmd({
|
||||
command: "scrap",
|
||||
builder: (yargs) => yargs,
|
||||
async handler() {},
|
||||
})
|
||||
@@ -8,6 +8,7 @@ import { Flag } from "../../flag/flag"
|
||||
import { Config } from "../../config/config"
|
||||
import { bootstrap } from "../bootstrap"
|
||||
import { MessageV2 } from "../../session/message-v2"
|
||||
import { Mode } from "../../session/mode"
|
||||
|
||||
const TOOL: Record<string, [string, string]> = {
|
||||
todowrite: ["Todo", UI.Style.TEXT_WARNING_BOLD],
|
||||
@@ -52,6 +53,10 @@ export const RunCommand = cmd({
|
||||
alias: ["m"],
|
||||
describe: "model to use in the format of provider/model",
|
||||
})
|
||||
.option("mode", {
|
||||
type: "string",
|
||||
describe: "mode to use",
|
||||
})
|
||||
},
|
||||
handler: async (args) => {
|
||||
let message = args.message.join(" ")
|
||||
@@ -61,7 +66,9 @@ export const RunCommand = cmd({
|
||||
await bootstrap({ cwd: process.cwd() }, async () => {
|
||||
const session = await (async () => {
|
||||
if (args.continue) {
|
||||
const first = await Session.list().next()
|
||||
const list = Session.list()
|
||||
const first = await list.next()
|
||||
await list.return()
|
||||
if (first.done) return
|
||||
return first.value
|
||||
}
|
||||
@@ -139,10 +146,17 @@ export const RunCommand = cmd({
|
||||
UI.error(err)
|
||||
})
|
||||
|
||||
const mode = args.mode ? await Mode.get(args.mode) : await Mode.list().then((x) => x[0])
|
||||
|
||||
const result = await Session.chat({
|
||||
sessionID: session.id,
|
||||
providerID,
|
||||
modelID,
|
||||
...(mode.model
|
||||
? mode.model
|
||||
: {
|
||||
providerID,
|
||||
modelID,
|
||||
}),
|
||||
mode: mode.name,
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Config } from "../../config/config"
|
||||
import { Bus } from "../../bus"
|
||||
import { Log } from "../../util/log"
|
||||
import { FileWatcher } from "../../file/watch"
|
||||
import { Mode } from "../../session/mode"
|
||||
|
||||
export const TuiCommand = cmd({
|
||||
command: "$0 [project]",
|
||||
@@ -30,6 +31,10 @@ export const TuiCommand = cmd({
|
||||
alias: ["p"],
|
||||
type: "string",
|
||||
describe: "prompt to use",
|
||||
})
|
||||
.option("mode", {
|
||||
type: "string",
|
||||
describe: "mode to use",
|
||||
}),
|
||||
handler: async (args) => {
|
||||
while (true) {
|
||||
@@ -77,6 +82,7 @@ export const TuiCommand = cmd({
|
||||
...cmd,
|
||||
...(args.model ? ["--model", args.model] : []),
|
||||
...(args.prompt ? ["--prompt", args.prompt] : []),
|
||||
...(args.mode ? ["--mode", args.mode] : []),
|
||||
],
|
||||
cwd,
|
||||
stdout: "inherit",
|
||||
@@ -87,6 +93,7 @@ export const TuiCommand = cmd({
|
||||
CGO_ENABLED: "0",
|
||||
OPENCODE_SERVER: server.url.toString(),
|
||||
OPENCODE_APP_INFO: JSON.stringify(app),
|
||||
OPENCODE_MODES: JSON.stringify(await Mode.list()),
|
||||
},
|
||||
onExit: () => {
|
||||
server.stop()
|
||||
|
||||
@@ -55,10 +55,22 @@ export namespace Config {
|
||||
export const Mcp = z.discriminatedUnion("type", [McpLocal, McpRemote])
|
||||
export type Mcp = z.infer<typeof Mcp>
|
||||
|
||||
export const Mode = z
|
||||
.object({
|
||||
model: z.string().optional(),
|
||||
prompt: z.string().optional(),
|
||||
tools: z.record(z.string(), z.boolean()).optional(),
|
||||
})
|
||||
.openapi({
|
||||
ref: "ModeConfig",
|
||||
})
|
||||
export type Mode = z.infer<typeof Mode>
|
||||
|
||||
export const Keybinds = z
|
||||
.object({
|
||||
leader: z.string().optional().default("ctrl+x").describe("Leader key for keybind combinations"),
|
||||
app_help: z.string().optional().default("<leader>h").describe("Show help dialog"),
|
||||
switch_mode: z.string().optional().default("tab").describe("Switch mode"),
|
||||
editor_open: z.string().optional().default("<leader>e").describe("Open external editor"),
|
||||
session_new: z.string().optional().default("<leader>n").describe("Create a new session"),
|
||||
session_list: z.string().optional().default("<leader>l").describe("List all sessions"),
|
||||
@@ -99,6 +111,7 @@ export namespace Config {
|
||||
.openapi({
|
||||
ref: "KeybindsConfig",
|
||||
})
|
||||
|
||||
export const Info = z
|
||||
.object({
|
||||
$schema: z.string().optional().describe("JSON schema reference for configuration validation"),
|
||||
@@ -108,6 +121,14 @@ export namespace Config {
|
||||
autoupdate: z.boolean().optional().describe("Automatically update to the latest version"),
|
||||
disabled_providers: z.array(z.string()).optional().describe("Disable providers that are loaded automatically"),
|
||||
model: z.string().describe("Model to use in the format of provider/model, eg anthropic/claude-2").optional(),
|
||||
mode: z
|
||||
.object({
|
||||
build: Mode.optional(),
|
||||
plan: Mode.optional(),
|
||||
})
|
||||
.catchall(Mode)
|
||||
.optional(),
|
||||
log_level: Log.Level.optional().describe("Minimum log level to write to log files"),
|
||||
provider: z
|
||||
.record(
|
||||
ModelsDev.Provider.partial().extend({
|
||||
@@ -174,19 +195,40 @@ export namespace Config {
|
||||
return result
|
||||
})
|
||||
|
||||
async function load(path: string) {
|
||||
const data = await Bun.file(path)
|
||||
.json()
|
||||
async function load(configPath: string) {
|
||||
let text = await Bun.file(configPath)
|
||||
.text()
|
||||
.catch((err) => {
|
||||
if (err.code === "ENOENT") return {}
|
||||
throw new JsonError({ path }, { cause: err })
|
||||
if (err.code === "ENOENT") return "{}"
|
||||
throw new JsonError({ path: configPath }, { cause: err })
|
||||
})
|
||||
|
||||
text = text.replace(/\{env:([^}]+)\}/g, (_, varName) => {
|
||||
return process.env[varName] || ""
|
||||
})
|
||||
|
||||
const fileMatches = text.match(/"?\{file:([^}]+)\}"?/g)
|
||||
if (fileMatches) {
|
||||
const configDir = path.dirname(configPath)
|
||||
for (const match of fileMatches) {
|
||||
const filePath = match.replace(/^"?\{file:/, "").replace(/\}"?$/, "")
|
||||
const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(configDir, filePath)
|
||||
const fileContent = await Bun.file(resolvedPath).text()
|
||||
text = text.replace(match, JSON.stringify(fileContent))
|
||||
}
|
||||
}
|
||||
|
||||
let data: any
|
||||
try {
|
||||
data = JSON.parse(text)
|
||||
} catch (err) {
|
||||
throw new JsonError({ path: configPath }, { cause: err as Error })
|
||||
}
|
||||
|
||||
const parsed = Info.safeParse(data)
|
||||
if (parsed.success) return parsed.data
|
||||
throw new InvalidError({ path, issues: parsed.error.issues })
|
||||
throw new InvalidError({ path: configPath, issues: parsed.error.issues })
|
||||
}
|
||||
|
||||
export const JsonError = NamedError.create(
|
||||
"ConfigJsonError",
|
||||
z.object({
|
||||
|
||||
@@ -11,6 +11,19 @@ import { Log } from "../util/log"
|
||||
export namespace File {
|
||||
const log = Log.create({ service: "file" })
|
||||
|
||||
export const Info = z
|
||||
.object({
|
||||
path: z.string(),
|
||||
added: z.number().int(),
|
||||
removed: z.number().int(),
|
||||
status: z.enum(["added", "deleted", "modified"]),
|
||||
})
|
||||
.openapi({
|
||||
ref: "File",
|
||||
})
|
||||
|
||||
export type Info = z.infer<typeof Info>
|
||||
|
||||
export const Event = {
|
||||
Edited: Bus.event(
|
||||
"file.edited",
|
||||
@@ -26,14 +39,14 @@ export namespace File {
|
||||
|
||||
const diffOutput = await $`git diff --numstat HEAD`.cwd(app.path.cwd).quiet().nothrow().text()
|
||||
|
||||
const changedFiles = []
|
||||
const changedFiles: Info[] = []
|
||||
|
||||
if (diffOutput.trim()) {
|
||||
const lines = diffOutput.trim().split("\n")
|
||||
for (const line of lines) {
|
||||
const [added, removed, filepath] = line.split("\t")
|
||||
changedFiles.push({
|
||||
file: filepath,
|
||||
path: filepath,
|
||||
added: added === "-" ? 0 : parseInt(added, 10),
|
||||
removed: removed === "-" ? 0 : parseInt(removed, 10),
|
||||
status: "modified",
|
||||
@@ -50,7 +63,7 @@ export namespace File {
|
||||
const content = await Bun.file(path.join(app.path.root, filepath)).text()
|
||||
const lines = content.split("\n").length
|
||||
changedFiles.push({
|
||||
file: filepath,
|
||||
path: filepath,
|
||||
added: lines,
|
||||
removed: 0,
|
||||
status: "added",
|
||||
@@ -68,7 +81,7 @@ export namespace File {
|
||||
const deletedFiles = deletedOutput.trim().split("\n")
|
||||
for (const filepath of deletedFiles) {
|
||||
changedFiles.push({
|
||||
file: filepath,
|
||||
path: filepath,
|
||||
added: 0,
|
||||
removed: 0, // Could get original line count but would require another git command
|
||||
status: "deleted",
|
||||
@@ -78,7 +91,7 @@ export namespace File {
|
||||
|
||||
return changedFiles.map((x) => ({
|
||||
...x,
|
||||
file: path.relative(app.path.cwd, path.join(app.path.root, x.file)),
|
||||
path: path.relative(app.path.cwd, path.join(app.path.root, x.path)),
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -34,25 +34,27 @@ export namespace Ripgrep {
|
||||
|
||||
export const Match = z.object({
|
||||
type: z.literal("match"),
|
||||
data: z.object({
|
||||
path: z.object({
|
||||
text: z.string(),
|
||||
}),
|
||||
lines: z.object({
|
||||
text: z.string(),
|
||||
}),
|
||||
line_number: z.number(),
|
||||
absolute_offset: z.number(),
|
||||
submatches: z.array(
|
||||
z.object({
|
||||
match: z.object({
|
||||
text: z.string(),
|
||||
}),
|
||||
start: z.number(),
|
||||
end: z.number(),
|
||||
data: z
|
||||
.object({
|
||||
path: z.object({
|
||||
text: z.string(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
lines: z.object({
|
||||
text: z.string(),
|
||||
}),
|
||||
line_number: z.number(),
|
||||
absolute_offset: z.number(),
|
||||
submatches: z.array(
|
||||
z.object({
|
||||
match: z.object({
|
||||
text: z.string(),
|
||||
}),
|
||||
start: z.number(),
|
||||
end: z.number(),
|
||||
}),
|
||||
),
|
||||
})
|
||||
.openapi({ ref: "Match" }),
|
||||
})
|
||||
|
||||
const End = z.object({
|
||||
|
||||
@@ -46,7 +46,7 @@ export namespace FileWatcher {
|
||||
)
|
||||
|
||||
export function init() {
|
||||
if (Flag.OPENCODE_DISABLE_WATCHER) return
|
||||
if (Flag.OPENCODE_DISABLE_WATCHER || true) return
|
||||
state()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { App } from "../app/app"
|
||||
import { BunProc } from "../bun"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import path from "path"
|
||||
|
||||
export interface Info {
|
||||
name: string
|
||||
@@ -29,7 +31,7 @@ export const mix: Info = {
|
||||
|
||||
export const prettier: Info = {
|
||||
name: "prettier",
|
||||
command: [BunProc.which(), "run", "prettier", "--write", "$FILE"],
|
||||
command: [BunProc.which(), "x", "prettier", "--write", "$FILE"],
|
||||
environment: {
|
||||
BUN_BE_BUN: "1",
|
||||
},
|
||||
@@ -62,23 +64,12 @@ export const prettier: Info = {
|
||||
".gql",
|
||||
],
|
||||
async enabled() {
|
||||
// this is more complicated because we only want to use prettier if it's
|
||||
// being used with the current project
|
||||
try {
|
||||
const proc = Bun.spawn({
|
||||
cmd: [BunProc.which(), "run", "prettier", "--version"],
|
||||
cwd: App.info().path.cwd,
|
||||
env: {
|
||||
BUN_BE_BUN: "1",
|
||||
},
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
})
|
||||
const exit = await proc.exited
|
||||
return exit === 0
|
||||
} catch {
|
||||
return false
|
||||
const app = App.info()
|
||||
const nms = await Filesystem.findUp("node_modules", app.path.cwd, app.path.root)
|
||||
for (const item of nms) {
|
||||
if (await Bun.file(path.join(item, ".bin", "prettier")).exists()) return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +40,24 @@ const cli = yargs(hideBin(process.argv))
|
||||
})
|
||||
.middleware(async () => {
|
||||
await Log.init({ print: process.argv.includes("--print-logs") })
|
||||
|
||||
try {
|
||||
const { Config } = await import("./config/config")
|
||||
const { App } = await import("./app/app")
|
||||
|
||||
App.provide({ cwd: process.cwd() }, async () => {
|
||||
const cfg = await Config.get()
|
||||
if (cfg.log_level) {
|
||||
Log.setLevel(cfg.log_level as Log.Level)
|
||||
} else {
|
||||
const defaultLevel = Installation.isDev() ? "DEBUG" : "INFO"
|
||||
Log.setLevel(defaultLevel)
|
||||
}
|
||||
})
|
||||
} catch (e) {
|
||||
Log.Default.error("failed to load config", { error: e })
|
||||
}
|
||||
|
||||
Log.Default.info("opencode", {
|
||||
version: Installation.VERSION,
|
||||
args: process.argv.slice(2),
|
||||
|
||||
@@ -184,9 +184,6 @@ export namespace LSPClient {
|
||||
},
|
||||
}
|
||||
|
||||
if (input.server.onInitialized) {
|
||||
await input.server.onInitialized(result)
|
||||
}
|
||||
l.info("initialized")
|
||||
|
||||
return result
|
||||
|
||||
@@ -4,66 +4,59 @@ import { LSPClient } from "./client"
|
||||
import path from "path"
|
||||
import { LSPServer } from "./server"
|
||||
import { z } from "zod"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
|
||||
export namespace LSP {
|
||||
const log = Log.create({ service: "lsp" })
|
||||
|
||||
export const Range = z
|
||||
.object({
|
||||
start: z.object({
|
||||
line: z.number(),
|
||||
character: z.number(),
|
||||
}),
|
||||
end: z.object({
|
||||
line: z.number(),
|
||||
character: z.number(),
|
||||
}),
|
||||
})
|
||||
.openapi({
|
||||
ref: "Range",
|
||||
})
|
||||
export type Range = z.infer<typeof Range>
|
||||
|
||||
export const Symbol = z
|
||||
.object({
|
||||
name: z.string(),
|
||||
kind: z.number(),
|
||||
location: z.object({
|
||||
uri: z.string(),
|
||||
range: z.object({
|
||||
start: z.object({
|
||||
line: z.number(),
|
||||
character: z.number(),
|
||||
}),
|
||||
end: z.object({
|
||||
line: z.number(),
|
||||
character: z.number(),
|
||||
}),
|
||||
}),
|
||||
range: Range,
|
||||
}),
|
||||
})
|
||||
.openapi({
|
||||
ref: "LSP.Symbol",
|
||||
ref: "Symbol",
|
||||
})
|
||||
export type Symbol = z.infer<typeof Symbol>
|
||||
|
||||
export const DocumentSymbol = z
|
||||
.object({
|
||||
name: z.string(),
|
||||
detail: z.string().optional(),
|
||||
kind: z.number(),
|
||||
range: Range,
|
||||
selectionRange: Range,
|
||||
})
|
||||
.openapi({
|
||||
ref: "DocumentSymbol",
|
||||
})
|
||||
export type DocumentSymbol = z.infer<typeof DocumentSymbol>
|
||||
|
||||
const state = App.state(
|
||||
"lsp",
|
||||
async (app) => {
|
||||
log.info("initializing")
|
||||
async () => {
|
||||
const clients: LSPClient.Info[] = []
|
||||
|
||||
for (const server of Object.values(LSPServer)) {
|
||||
const roots = await server.roots(app)
|
||||
|
||||
for (const root of roots) {
|
||||
if (!Filesystem.overlaps(app.path.cwd, root)) continue
|
||||
log.info("", {
|
||||
root,
|
||||
serverID: server.id,
|
||||
})
|
||||
const handle = await server.spawn(App.info(), root)
|
||||
if (!handle) break
|
||||
const client = await LSPClient.create({
|
||||
serverID: server.id,
|
||||
server: handle,
|
||||
root,
|
||||
}).catch((err) => {
|
||||
handle.process.kill()
|
||||
log.error("", { error: err })
|
||||
})
|
||||
if (!client) break
|
||||
clients.push(client)
|
||||
}
|
||||
}
|
||||
|
||||
log.info("initialized")
|
||||
return {
|
||||
broken: new Set<string>(),
|
||||
clients,
|
||||
}
|
||||
},
|
||||
@@ -78,13 +71,43 @@ export namespace LSP {
|
||||
return state()
|
||||
}
|
||||
|
||||
async function getClients(file: string) {
|
||||
const s = await state()
|
||||
const extension = path.parse(file).ext
|
||||
const result: LSPClient.Info[] = []
|
||||
for (const server of Object.values(LSPServer)) {
|
||||
if (!server.extensions.includes(extension)) continue
|
||||
const root = await server.root(file, App.info())
|
||||
if (!root) continue
|
||||
if (s.broken.has(root + server.id)) continue
|
||||
|
||||
const match = s.clients.find((x) => x.root === root && x.serverID === server.id)
|
||||
if (match) {
|
||||
result.push(match)
|
||||
continue
|
||||
}
|
||||
const handle = await server.spawn(App.info(), root)
|
||||
if (!handle) continue
|
||||
const client = await LSPClient.create({
|
||||
serverID: server.id,
|
||||
server: handle,
|
||||
root,
|
||||
}).catch((err) => {
|
||||
s.broken.add(root + server.id)
|
||||
handle.process.kill()
|
||||
log.error("", { error: err })
|
||||
})
|
||||
if (!client) continue
|
||||
s.clients.push(client)
|
||||
result.push(client)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export async function touchFile(input: string, waitForDiagnostics?: boolean) {
|
||||
const extension = path.parse(input).ext
|
||||
const matches = Object.values(LSPServer)
|
||||
.filter((x) => x.extensions.includes(extension))
|
||||
.map((x) => x.id)
|
||||
const clients = await getClients(input)
|
||||
await run(async (client) => {
|
||||
if (!matches.includes(client.serverID)) return
|
||||
if (!clients.includes(client)) return
|
||||
const wait = waitForDiagnostics ? client.waitForDiagnostics({ path: input }) : Promise.resolve()
|
||||
await client.notify.open({ path: input })
|
||||
return wait
|
||||
@@ -117,17 +140,72 @@ export namespace LSP {
|
||||
})
|
||||
}
|
||||
|
||||
enum SymbolKind {
|
||||
File = 1,
|
||||
Module = 2,
|
||||
Namespace = 3,
|
||||
Package = 4,
|
||||
Class = 5,
|
||||
Method = 6,
|
||||
Property = 7,
|
||||
Field = 8,
|
||||
Constructor = 9,
|
||||
Enum = 10,
|
||||
Interface = 11,
|
||||
Function = 12,
|
||||
Variable = 13,
|
||||
Constant = 14,
|
||||
String = 15,
|
||||
Number = 16,
|
||||
Boolean = 17,
|
||||
Array = 18,
|
||||
Object = 19,
|
||||
Key = 20,
|
||||
Null = 21,
|
||||
EnumMember = 22,
|
||||
Struct = 23,
|
||||
Event = 24,
|
||||
Operator = 25,
|
||||
TypeParameter = 26,
|
||||
}
|
||||
|
||||
const kinds = [
|
||||
SymbolKind.Class,
|
||||
SymbolKind.Function,
|
||||
SymbolKind.Method,
|
||||
SymbolKind.Interface,
|
||||
SymbolKind.Variable,
|
||||
SymbolKind.Constant,
|
||||
SymbolKind.Struct,
|
||||
SymbolKind.Enum,
|
||||
]
|
||||
|
||||
export async function workspaceSymbol(query: string) {
|
||||
return run((client) =>
|
||||
client.connection
|
||||
.sendRequest("workspace/symbol", {
|
||||
query,
|
||||
})
|
||||
.then((result: any) => result.filter((x: LSP.Symbol) => kinds.includes(x.kind)))
|
||||
.then((result: any) => result.slice(0, 10))
|
||||
.catch(() => []),
|
||||
).then((result) => result.flat() as LSP.Symbol[])
|
||||
}
|
||||
|
||||
export async function documentSymbol(uri: string) {
|
||||
return run((client) =>
|
||||
client.connection
|
||||
.sendRequest("textDocument/documentSymbol", {
|
||||
textDocument: {
|
||||
uri,
|
||||
},
|
||||
})
|
||||
.catch(() => []),
|
||||
)
|
||||
.then((result) => result.flat() as (LSP.DocumentSymbol | LSP.Symbol)[])
|
||||
.then((result) => result.filter(Boolean))
|
||||
}
|
||||
|
||||
async function run<T>(input: (client: LSPClient.Info) => Promise<T>): Promise<T[]> {
|
||||
const clients = await state().then((x) => x.clients)
|
||||
const tasks = clients.map((x) => input(x))
|
||||
|
||||
@@ -6,10 +6,7 @@ import { Log } from "../util/log"
|
||||
import { BunProc } from "../bun"
|
||||
import { $ } from "bun"
|
||||
import fs from "fs/promises"
|
||||
import { unique } from "remeda"
|
||||
import { Ripgrep } from "../file/ripgrep"
|
||||
import type { LSPClient } from "./client"
|
||||
import { withTimeout } from "../util/timeout"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
|
||||
export namespace LSPServer {
|
||||
const log = Log.create({ service: "lsp.server" })
|
||||
@@ -17,20 +14,21 @@ export namespace LSPServer {
|
||||
export interface Handle {
|
||||
process: ChildProcessWithoutNullStreams
|
||||
initialization?: Record<string, any>
|
||||
onInitialized?: (lsp: LSPClient.Info) => Promise<void>
|
||||
}
|
||||
|
||||
type RootsFunction = (app: App.Info) => Promise<string[]>
|
||||
type RootFunction = (file: string, app: App.Info) => Promise<string | undefined>
|
||||
|
||||
const SimpleRoots = (patterns: string[]): RootsFunction => {
|
||||
return async (app) => {
|
||||
const glob = `**/*/{${patterns.join(",")}}`
|
||||
const files = await Ripgrep.files({
|
||||
glob: [glob],
|
||||
cwd: app.path.root,
|
||||
const NearestRoot = (patterns: string[]): RootFunction => {
|
||||
return async (file, app) => {
|
||||
const files = Filesystem.up({
|
||||
targets: patterns,
|
||||
start: path.dirname(file),
|
||||
stop: app.path.root,
|
||||
})
|
||||
const dirs = files.map((file) => path.dirname(file))
|
||||
return unique(dirs).map((dir) => path.join(app.path.root, dir))
|
||||
const first = await files.next()
|
||||
await files.return()
|
||||
if (!first.value) return app.path.root
|
||||
return path.dirname(first.value)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,13 +36,13 @@ export namespace LSPServer {
|
||||
id: string
|
||||
extensions: string[]
|
||||
global?: boolean
|
||||
roots: (app: App.Info) => Promise<string[]>
|
||||
root: RootFunction
|
||||
spawn(app: App.Info, root: string): Promise<Handle | undefined>
|
||||
}
|
||||
|
||||
export const Typescript: Info = {
|
||||
id: "typescript",
|
||||
roots: SimpleRoots(["tsconfig.json", "jsconfig.json", "package.json"]),
|
||||
root: NearestRoot(["tsconfig.json", "package.json", "jsconfig.json"]),
|
||||
extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"],
|
||||
async spawn(app, root) {
|
||||
const tsserver = await Bun.resolve("typescript/lib/tsserver.js", app.path.cwd).catch(() => {})
|
||||
@@ -63,30 +61,17 @@ export namespace LSPServer {
|
||||
path: tsserver,
|
||||
},
|
||||
},
|
||||
// tsserver sucks and won't start processing codebase until you open a file
|
||||
onInitialized: async (lsp) => {
|
||||
const [hint] = await Ripgrep.files({
|
||||
cwd: lsp.root,
|
||||
glob: ["*.ts", "*.tsx", "*.js", "*.jsx", "*.mjs", "*.cjs", "*.mts", "*.cts"],
|
||||
limit: 1,
|
||||
})
|
||||
const wait = new Promise<void>(async (resolve) => {
|
||||
const notif = lsp.connection.onNotification("$/progress", (params) => {
|
||||
if (params.value.kind !== "end") return
|
||||
notif.dispose()
|
||||
resolve()
|
||||
})
|
||||
await lsp.notify.open({ path: path.join(lsp.root, hint) })
|
||||
})
|
||||
await withTimeout(wait, 5_000)
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
export const Gopls: Info = {
|
||||
id: "golang",
|
||||
roots: SimpleRoots(["go.mod", "go.sum"]),
|
||||
root: async (file, app) => {
|
||||
const work = await NearestRoot(["go.work"])(file, app)
|
||||
if (work) return work
|
||||
return NearestRoot(["go.mod", "go.sum"])(file, app)
|
||||
},
|
||||
extensions: [".go"],
|
||||
async spawn(_, root) {
|
||||
let bin = Bun.which("gopls", {
|
||||
@@ -122,7 +107,7 @@ export namespace LSPServer {
|
||||
|
||||
export const RubyLsp: Info = {
|
||||
id: "ruby-lsp",
|
||||
roots: SimpleRoots(["Gemfile"]),
|
||||
root: NearestRoot(["Gemfile"]),
|
||||
extensions: [".rb", ".rake", ".gemspec", ".ru"],
|
||||
async spawn(_, root) {
|
||||
let bin = Bun.which("ruby-lsp", {
|
||||
@@ -163,14 +148,7 @@ export namespace LSPServer {
|
||||
export const Pyright: Info = {
|
||||
id: "pyright",
|
||||
extensions: [".py", ".pyi"],
|
||||
roots: SimpleRoots([
|
||||
"pyproject.toml",
|
||||
"setup.py",
|
||||
"setup.cfg",
|
||||
"requirements.txt",
|
||||
"Pipfile",
|
||||
"pyrightconfig.json",
|
||||
]),
|
||||
root: NearestRoot(["pyproject.toml", "setup.py", "setup.cfg", "requirements.txt", "Pipfile", "pyrightconfig.json"]),
|
||||
async spawn(_, root) {
|
||||
const proc = spawn(BunProc.which(), ["x", "pyright-langserver", "--stdio"], {
|
||||
cwd: root,
|
||||
@@ -188,7 +166,7 @@ export namespace LSPServer {
|
||||
export const ElixirLS: Info = {
|
||||
id: "elixir-ls",
|
||||
extensions: [".ex", ".exs"],
|
||||
roots: SimpleRoots(["mix.exs", "mix.lock"]),
|
||||
root: NearestRoot(["mix.exs", "mix.lock"]),
|
||||
async spawn(_, root) {
|
||||
let binary = Bun.which("elixir-ls")
|
||||
if (!binary) {
|
||||
@@ -239,4 +217,109 @@ export namespace LSPServer {
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
export const Zls: Info = {
|
||||
id: "zls",
|
||||
extensions: [".zig", ".zon"],
|
||||
root: NearestRoot(["build.zig"]),
|
||||
async spawn(_, root) {
|
||||
let bin = Bun.which("zls", {
|
||||
PATH: process.env["PATH"] + ":" + Global.Path.bin,
|
||||
})
|
||||
|
||||
if (!bin) {
|
||||
const zig = Bun.which("zig")
|
||||
if (!zig) {
|
||||
log.error("Zig is required to use zls. Please install Zig first.")
|
||||
return
|
||||
}
|
||||
|
||||
log.info("downloading zls from GitHub releases")
|
||||
|
||||
const releaseResponse = await fetch("https://api.github.com/repos/zigtools/zls/releases/latest")
|
||||
if (!releaseResponse.ok) {
|
||||
log.error("Failed to fetch zls release info")
|
||||
return
|
||||
}
|
||||
|
||||
const release = await releaseResponse.json()
|
||||
|
||||
const platform = process.platform
|
||||
const arch = process.arch
|
||||
let assetName = ""
|
||||
|
||||
let zlsArch: string = arch
|
||||
if (arch === "arm64") zlsArch = "aarch64"
|
||||
else if (arch === "x64") zlsArch = "x86_64"
|
||||
else if (arch === "ia32") zlsArch = "x86"
|
||||
|
||||
let zlsPlatform: string = platform
|
||||
if (platform === "darwin") zlsPlatform = "macos"
|
||||
else if (platform === "win32") zlsPlatform = "windows"
|
||||
|
||||
const ext = platform === "win32" ? "zip" : "tar.xz"
|
||||
|
||||
assetName = `zls-${zlsArch}-${zlsPlatform}.${ext}`
|
||||
|
||||
const supportedCombos = [
|
||||
"zls-x86_64-linux.tar.xz",
|
||||
"zls-x86_64-macos.tar.xz",
|
||||
"zls-x86_64-windows.zip",
|
||||
"zls-aarch64-linux.tar.xz",
|
||||
"zls-aarch64-macos.tar.xz",
|
||||
"zls-aarch64-windows.zip",
|
||||
"zls-x86-linux.tar.xz",
|
||||
"zls-x86-windows.zip",
|
||||
]
|
||||
|
||||
if (!supportedCombos.includes(assetName)) {
|
||||
log.error(`Platform ${platform} and architecture ${arch} is not supported by zls`)
|
||||
return
|
||||
}
|
||||
|
||||
const asset = release.assets.find((a: any) => a.name === assetName)
|
||||
if (!asset) {
|
||||
log.error(`Could not find asset ${assetName} in latest zls release`)
|
||||
return
|
||||
}
|
||||
|
||||
const downloadUrl = asset.browser_download_url
|
||||
const downloadResponse = await fetch(downloadUrl)
|
||||
if (!downloadResponse.ok) {
|
||||
log.error("Failed to download zls")
|
||||
return
|
||||
}
|
||||
|
||||
const tempPath = path.join(Global.Path.bin, assetName)
|
||||
await Bun.file(tempPath).write(downloadResponse)
|
||||
|
||||
if (ext === "zip") {
|
||||
await $`unzip -o -q ${tempPath}`.cwd(Global.Path.bin).nothrow()
|
||||
} else {
|
||||
await $`tar -xf ${tempPath}`.cwd(Global.Path.bin).nothrow()
|
||||
}
|
||||
|
||||
await fs.rm(tempPath, { force: true })
|
||||
|
||||
bin = path.join(Global.Path.bin, "zls" + (platform === "win32" ? ".exe" : ""))
|
||||
|
||||
if (!(await Bun.file(bin).exists())) {
|
||||
log.error("Failed to extract zls binary")
|
||||
return
|
||||
}
|
||||
|
||||
if (platform !== "win32") {
|
||||
await $`chmod +x ${bin}`.nothrow()
|
||||
}
|
||||
|
||||
log.info(`installed zls`, { bin })
|
||||
}
|
||||
|
||||
return {
|
||||
process: spawn(bin, {
|
||||
cwd: root,
|
||||
}),
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { Config } from "../config/config"
|
||||
import { File } from "../file"
|
||||
import { LSP } from "../lsp"
|
||||
import { MessageV2 } from "../session/message-v2"
|
||||
import { Mode } from "../session/mode"
|
||||
|
||||
const ERRORS = {
|
||||
400: {
|
||||
@@ -447,6 +448,7 @@ export namespace Server {
|
||||
z.object({
|
||||
providerID: z.string(),
|
||||
modelID: z.string(),
|
||||
mode: z.string(),
|
||||
parts: MessageV2.UserPart.array(),
|
||||
}),
|
||||
),
|
||||
@@ -558,7 +560,7 @@ export namespace Server {
|
||||
description: "Symbols",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.unknown().array()),
|
||||
schema: resolver(LSP.Symbol.array()),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -621,16 +623,7 @@ export namespace Server {
|
||||
description: "File status",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(
|
||||
z
|
||||
.object({
|
||||
file: z.string(),
|
||||
added: z.number().int(),
|
||||
removed: z.number().int(),
|
||||
status: z.enum(["added", "deleted", "modified"]),
|
||||
})
|
||||
.array(),
|
||||
),
|
||||
schema: resolver(File.Info.array()),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -641,6 +634,75 @@ export namespace Server {
|
||||
return c.json(content)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/log",
|
||||
describeRoute({
|
||||
description: "Write a log entry to the server logs",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Log entry written successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
zValidator(
|
||||
"json",
|
||||
z.object({
|
||||
service: z.string().openapi({ description: "Service name for the log entry" }),
|
||||
level: z.enum(["debug", "info", "error", "warn"]).openapi({ description: "Log level" }),
|
||||
message: z.string().openapi({ description: "Log message" }),
|
||||
extra: z
|
||||
.record(z.string(), z.any())
|
||||
.optional()
|
||||
.openapi({ description: "Additional metadata for the log entry" }),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const { service, level, message, extra } = c.req.valid("json")
|
||||
const logger = Log.create({ service })
|
||||
|
||||
switch (level) {
|
||||
case "debug":
|
||||
logger.debug(message, extra)
|
||||
break
|
||||
case "info":
|
||||
logger.info(message, extra)
|
||||
break
|
||||
case "error":
|
||||
logger.error(message, extra)
|
||||
break
|
||||
case "warn":
|
||||
logger.warn(message, extra)
|
||||
break
|
||||
}
|
||||
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/mode",
|
||||
describeRoute({
|
||||
description: "List all modes",
|
||||
responses: {
|
||||
200: {
|
||||
description: "List of modes",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Mode.Info.array()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const modes = await Mode.list()
|
||||
return c.json(modes)
|
||||
},
|
||||
)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from "ai"
|
||||
|
||||
import PROMPT_INITIALIZE from "../session/prompt/initialize.txt"
|
||||
import PROMPT_PLAN from "../session/prompt/plan.txt"
|
||||
|
||||
import { App } from "../app/app"
|
||||
import { Bus } from "../bus"
|
||||
@@ -29,16 +30,20 @@ import type { ModelsDev } from "../provider/models"
|
||||
import { Share } from "../share/share"
|
||||
import { Snapshot } from "../snapshot"
|
||||
import { Storage } from "../storage/storage"
|
||||
import type { Tool } from "../tool/tool"
|
||||
import { Log } from "../util/log"
|
||||
import { NamedError } from "../util/error"
|
||||
import { SystemPrompt } from "./system"
|
||||
import { FileTime } from "../file/time"
|
||||
import { MessageV2 } from "./message-v2"
|
||||
import { Mode } from "./mode"
|
||||
import { LSP } from "../lsp"
|
||||
import { ReadTool } from "../tool/read"
|
||||
|
||||
export namespace Session {
|
||||
const log = Log.create({ service: "session" })
|
||||
|
||||
const OUTPUT_TOKEN_MAX = 32_000
|
||||
|
||||
export const Info = z
|
||||
.object({
|
||||
id: Identifier.schema("session"),
|
||||
@@ -281,13 +286,12 @@ export namespace Session {
|
||||
sessionID: string
|
||||
providerID: string
|
||||
modelID: string
|
||||
mode?: string
|
||||
parts: MessageV2.UserPart[]
|
||||
system?: string[]
|
||||
tools?: Tool.Info[]
|
||||
}) {
|
||||
using abort = lock(input.sessionID)
|
||||
const l = log.clone().tag("session", input.sessionID)
|
||||
l.info("chatting")
|
||||
|
||||
const model = await Provider.getModel(input.providerID, input.modelID)
|
||||
let msgs = await messages(input.sessionID)
|
||||
const session = await get(input.sessionID)
|
||||
@@ -317,15 +321,13 @@ export namespace Session {
|
||||
}
|
||||
|
||||
const previous = msgs.at(-1) as MessageV2.Assistant
|
||||
const outputLimit = Math.min(model.info.limit.output, OUTPUT_TOKEN_MAX)
|
||||
|
||||
// auto summarize if too long
|
||||
if (previous) {
|
||||
const tokens =
|
||||
previous.tokens.input + previous.tokens.cache.read + previous.tokens.cache.write + previous.tokens.output
|
||||
if (
|
||||
model.info.limit.context &&
|
||||
tokens > Math.max((model.info.limit.context - (model.info.limit.output ?? 0)) * 0.9, 0)
|
||||
) {
|
||||
if (model.info.limit.context && tokens > Math.max((model.info.limit.context - outputLimit) * 0.9, 0)) {
|
||||
await summarize({
|
||||
sessionID: input.sessionID,
|
||||
providerID: input.providerID,
|
||||
@@ -335,6 +337,8 @@ export namespace Session {
|
||||
}
|
||||
}
|
||||
|
||||
using abort = lock(input.sessionID)
|
||||
|
||||
const lastSummary = msgs.findLast((msg) => msg.role === "assistant" && msg.summary === true)
|
||||
if (lastSummary) msgs = msgs.filter((msg) => msg.id >= lastSummary.id)
|
||||
|
||||
@@ -345,34 +349,75 @@ export namespace Session {
|
||||
const url = new URL(part.url)
|
||||
switch (url.protocol) {
|
||||
case "file:":
|
||||
const filepath = path.join(app.path.cwd, url.pathname)
|
||||
let file = Bun.file(filepath)
|
||||
// have to normalize, symbol search returns absolute paths
|
||||
// Decode the pathname since URL constructor doesn't automatically decode it
|
||||
const pathname = decodeURIComponent(url.pathname)
|
||||
const relativePath = pathname.replace(app.path.cwd, ".")
|
||||
const filePath = path.join(app.path.cwd, relativePath)
|
||||
|
||||
if (part.mime === "text/plain") {
|
||||
let text = await file.text()
|
||||
let offset: number | undefined = undefined
|
||||
let limit: number | undefined = undefined
|
||||
const range = {
|
||||
start: url.searchParams.get("start"),
|
||||
end: url.searchParams.get("end"),
|
||||
}
|
||||
if (range.start != null && part.mime === "text/plain") {
|
||||
const lines = text.split("\n")
|
||||
const start = parseInt(range.start)
|
||||
const end = range.end ? parseInt(range.end) : lines.length
|
||||
text = lines.slice(start, end).join("\n")
|
||||
if (range.start != null) {
|
||||
const filePath = part.url.split("?")[0]
|
||||
let start = parseInt(range.start)
|
||||
let end = range.end ? parseInt(range.end) : undefined
|
||||
// some LSP servers (eg, gopls) don't give full range in
|
||||
// workspace/symbol searches, so we'll try to find the
|
||||
// symbol in the document to get the full range
|
||||
if (start === end) {
|
||||
const symbols = await LSP.documentSymbol(filePath)
|
||||
for (const symbol of symbols) {
|
||||
let range: LSP.Range | undefined
|
||||
if ("range" in symbol) {
|
||||
range = symbol.range
|
||||
} else if ("location" in symbol) {
|
||||
range = symbol.location.range
|
||||
}
|
||||
if (range?.start?.line && range?.start?.line === start) {
|
||||
start = range.start.line
|
||||
end = range?.end?.line ?? start
|
||||
break
|
||||
}
|
||||
}
|
||||
offset = Math.max(start - 2, 0)
|
||||
if (end) {
|
||||
limit = end - offset + 2
|
||||
}
|
||||
}
|
||||
}
|
||||
FileTime.read(input.sessionID, filepath)
|
||||
const args = { filePath, offset, limit }
|
||||
const result = await ReadTool.execute(args, {
|
||||
sessionID: input.sessionID,
|
||||
abort: abort.signal,
|
||||
messageID: "", // read tool doesn't use message ID
|
||||
metadata: async () => {},
|
||||
})
|
||||
return [
|
||||
{
|
||||
type: "text",
|
||||
text: ["Called the Read tool on " + url.pathname, "<results>", text, "</results>"].join("\n"),
|
||||
synthetic: true,
|
||||
text: `Called the Read tool with the following input: ${JSON.stringify(args)}`,
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
synthetic: true,
|
||||
text: result.output,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
let file = Bun.file(filePath)
|
||||
FileTime.read(input.sessionID, filePath)
|
||||
return [
|
||||
{
|
||||
type: "text",
|
||||
text: `Called the Read tool with the following input: {\"filePath\":\"${url.pathname}\"}`,
|
||||
text: `Called the Read tool with the following input: {\"filePath\":\"${pathname}\"}`,
|
||||
synthetic: true,
|
||||
},
|
||||
{
|
||||
type: "file",
|
||||
@@ -386,6 +431,14 @@ export namespace Session {
|
||||
return [part]
|
||||
}),
|
||||
).then((x) => x.flat())
|
||||
|
||||
if (input.mode === "plan")
|
||||
input.parts.push({
|
||||
type: "text",
|
||||
text: PROMPT_PLAN,
|
||||
synthetic: true,
|
||||
})
|
||||
|
||||
if (msgs.length === 0 && !session.parentID) {
|
||||
generateText({
|
||||
maxOutputTokens: input.providerID === "google" ? 1024 : 20,
|
||||
@@ -431,9 +484,13 @@ export namespace Session {
|
||||
await updateMessage(msg)
|
||||
msgs.push(msg)
|
||||
|
||||
const system = input.system ?? SystemPrompt.provider(input.providerID)
|
||||
const mode = await Mode.get(input.mode ?? "build")
|
||||
let system = mode.prompt ? [mode.prompt] : SystemPrompt.provider(input.providerID, input.modelID)
|
||||
system.push(...(await SystemPrompt.environment()))
|
||||
system.push(...(await SystemPrompt.custom()))
|
||||
// max 2 system prompt messages for caching purposes
|
||||
const [first, ...rest] = system
|
||||
system = [first, rest.join("\n")]
|
||||
|
||||
const next: MessageV2.Info = {
|
||||
id: Identifier.ascending("message"),
|
||||
@@ -462,7 +519,8 @@ export namespace Session {
|
||||
const tools: Record<string, AITool> = {}
|
||||
|
||||
for (const item of await Provider.tools(input.providerID)) {
|
||||
tools[item.id.replaceAll(".", "_")] = tool({
|
||||
if (mode.tools[item.id] === false) continue
|
||||
tools[item.id] = tool({
|
||||
id: item.id as any,
|
||||
description: item.description,
|
||||
inputSchema: item.parameters as ZodSchema,
|
||||
@@ -494,6 +552,7 @@ export namespace Session {
|
||||
}
|
||||
|
||||
for (const [key, item] of Object.entries(await MCP.tools())) {
|
||||
if (mode.tools[key] === false) continue
|
||||
const execute = item.execute
|
||||
if (!execute) continue
|
||||
item.execute = async (args, opts) => {
|
||||
@@ -523,7 +582,7 @@ export namespace Session {
|
||||
const result = streamText({
|
||||
onError() {},
|
||||
maxRetries: 10,
|
||||
maxOutputTokens: Math.max(0, model.info.limit.output) || undefined,
|
||||
maxOutputTokens: outputLimit,
|
||||
abortSignal: abort.signal,
|
||||
stopWhen: stepCountIs(1000),
|
||||
providerOptions: model.info.options,
|
||||
@@ -662,6 +721,11 @@ export namespace Session {
|
||||
const usage = getUsage(model.info, value.usage, value.providerMetadata)
|
||||
next.cost += usage.cost
|
||||
next.tokens = usage.tokens
|
||||
next.parts.push({
|
||||
type: "step-finish",
|
||||
tokens: usage.tokens,
|
||||
cost: usage.cost,
|
||||
})
|
||||
break
|
||||
|
||||
case "text-start":
|
||||
|
||||
@@ -76,6 +76,7 @@ export namespace MessageV2 {
|
||||
.object({
|
||||
type: z.literal("text"),
|
||||
text: z.string(),
|
||||
synthetic: z.boolean().optional(),
|
||||
})
|
||||
.openapi({
|
||||
ref: "TextPart",
|
||||
@@ -115,6 +116,25 @@ export namespace MessageV2 {
|
||||
})
|
||||
export type StepStartPart = z.infer<typeof StepStartPart>
|
||||
|
||||
export const StepFinishPart = z
|
||||
.object({
|
||||
type: z.literal("step-finish"),
|
||||
cost: z.number(),
|
||||
tokens: z.object({
|
||||
input: z.number(),
|
||||
output: z.number(),
|
||||
reasoning: z.number(),
|
||||
cache: z.object({
|
||||
read: z.number(),
|
||||
write: z.number(),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
.openapi({
|
||||
ref: "StepFinishPart",
|
||||
})
|
||||
export type StepFinishPart = z.infer<typeof StepFinishPart>
|
||||
|
||||
const Base = z.object({
|
||||
id: z.string(),
|
||||
sessionID: z.string(),
|
||||
@@ -136,9 +156,11 @@ export namespace MessageV2 {
|
||||
})
|
||||
export type User = z.infer<typeof User>
|
||||
|
||||
export const AssistantPart = z.discriminatedUnion("type", [TextPart, ToolPart, StepStartPart]).openapi({
|
||||
ref: "AssistantMessagePart",
|
||||
})
|
||||
export const AssistantPart = z
|
||||
.discriminatedUnion("type", [TextPart, ToolPart, StepStartPart, StepFinishPart])
|
||||
.openapi({
|
||||
ref: "AssistantMessagePart",
|
||||
})
|
||||
export type AssistantPart = z.infer<typeof AssistantPart>
|
||||
|
||||
export const Assistant = Base.extend({
|
||||
@@ -163,8 +185,8 @@ export namespace MessageV2 {
|
||||
cwd: z.string(),
|
||||
root: z.string(),
|
||||
}),
|
||||
cost: z.number(),
|
||||
summary: z.boolean().optional(),
|
||||
cost: z.number(),
|
||||
tokens: z.object({
|
||||
input: z.number(),
|
||||
output: z.number(),
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { mergeDeep } from "remeda"
|
||||
import { App } from "../app/app"
|
||||
import { Config } from "../config/config"
|
||||
import z from "zod"
|
||||
|
||||
export namespace Mode {
|
||||
export const Info = z
|
||||
.object({
|
||||
name: z.string(),
|
||||
model: z
|
||||
.object({
|
||||
modelID: z.string(),
|
||||
providerID: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
prompt: z.string().optional(),
|
||||
tools: z.record(z.boolean()),
|
||||
})
|
||||
.openapi({
|
||||
ref: "Mode",
|
||||
})
|
||||
export type Info = z.infer<typeof Info>
|
||||
const state = App.state("mode", async () => {
|
||||
const cfg = await Config.get()
|
||||
const mode = mergeDeep(
|
||||
{
|
||||
build: {},
|
||||
plan: {
|
||||
tools: {
|
||||
write: false,
|
||||
edit: false,
|
||||
patch: false,
|
||||
bash: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
cfg.mode ?? {},
|
||||
)
|
||||
const result: Record<string, Info> = {}
|
||||
for (const [key, value] of Object.entries(mode)) {
|
||||
let item = result[key]
|
||||
if (!item)
|
||||
item = result[key] = {
|
||||
name: key,
|
||||
tools: {},
|
||||
}
|
||||
const model = value.model ?? cfg.model
|
||||
if (model) {
|
||||
const [providerID, ...rest] = model.split("/")
|
||||
const modelID = rest.join("/")
|
||||
item.model = {
|
||||
modelID,
|
||||
providerID,
|
||||
}
|
||||
}
|
||||
if (value.prompt) item.prompt = await Bun.file(value.prompt).text()
|
||||
if (value.tools) item.tools = value.tools
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
export async function get(mode: string) {
|
||||
return state().then((x) => x[mode])
|
||||
}
|
||||
|
||||
export async function list() {
|
||||
return state().then((x) => Object.values(x))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
You are an agent known as opencode - please keep going until the user’s query is completely resolved, before ending your turn and yielding back to the user.
|
||||
|
||||
Your thinking should be thorough and so it's fine if it's very long. However, avoid unnecessary repetition and verbosity. You should be concise, but thorough.
|
||||
|
||||
You MUST iterate and keep going until the problem is solved.
|
||||
|
||||
I want you to fully solve this autonomously before coming back to me.
|
||||
|
||||
Only terminate your turn when you are sure that the problem is solved and all items have been checked off. Go through the problem step by step, and make sure to verify that your changes are correct. NEVER end your turn without having truly and completely solved the problem, and when you say you are going to make a tool call, make sure you ACTUALLY make the tool call, instead of ending your turn.
|
||||
|
||||
Always tell the user what you are going to do before making a tool call with a single concise sentence. This will help them understand what you are doing and why.
|
||||
|
||||
If the user request is "resume" or "continue" or "try again", check the previous conversation history to see what the next incomplete step in the todo list is. Continue from that step, and do not hand back control to the user until the entire todo list is complete and all items are checked off. Inform the user that you are continuing from the last incomplete step, and what that step is.
|
||||
|
||||
Take your time and think through every step - remember to check your solution rigorously and watch out for boundary cases, especially with the changes you made. Your solution must be perfect. If not, continue working on it. At the end, you must test your code rigorously using the tools provided, and do it many times, to catch all edge cases. If it is not robust, iterate more and make it perfect. Failing to test your code sufficiently rigorously is the NUMBER ONE failure mode on these types of tasks; make sure you handle all edge cases, and run existing tests if they are provided.
|
||||
|
||||
You MUST plan extensively before each function call, and reflect extensively on the outcomes of the previous function calls. DO NOT do this entire process by making function calls only, as this can impair your ability to solve the problem and think insightfully.
|
||||
|
||||
# Workflow
|
||||
|
||||
1. Understand the problem deeply. Carefully read the issue and think critically about what is required.
|
||||
2. Investigate the codebase. Explore relevant files, search for key functions, and gather context.
|
||||
3. Develop a clear, step-by-step plan. Break down the fix into manageable, incremental steps. Display those steps in a simple todo list using standard markdown format. Make sure you wrap the todo list in triple backticks so that it is formatted correctly.
|
||||
4. Implement the fix incrementally. Make small, testable code changes.
|
||||
5. Debug as needed. Use debugging techniques to isolate and resolve issues.
|
||||
6. Test frequently. Run tests after each change to verify correctness.
|
||||
7. Iterate until the root cause is fixed and all tests pass.
|
||||
8. Reflect and validate comprehensively. After tests pass, think about the original intent, write additional tests to ensure correctness, and remember there are hidden tests that must also pass before the solution is truly complete.
|
||||
|
||||
Refer to the detailed sections below for more information on each step.
|
||||
|
||||
## 1. Deeply Understand the Problem
|
||||
Carefully read the issue and think hard about a plan to solve it before coding.
|
||||
|
||||
## 2. Codebase Investigation
|
||||
- Explore relevant files and directories.
|
||||
- Search for key functions, classes, or variables related to the issue.
|
||||
- Read and understand relevant code snippets.
|
||||
- Identify the root cause of the problem.
|
||||
- Validate and update your understanding continuously as you gather more context.
|
||||
|
||||
## 3. Fetch Provided URLs
|
||||
- If the user provides a URL, use the `functions.fetch_webpage` tool to retrieve the content of the provided URL.
|
||||
- After fetching, review the content returned by the fetch tool.
|
||||
- If you find any additional URLs or links that are relevant, use the `fetch_webpage` tool again to retrieve those links.
|
||||
- Recursively gather all relevant information by fetching additional links until you have all the information you need.
|
||||
|
||||
## 4. Develop a Detailed Plan
|
||||
- Outline a specific, simple, and verifiable sequence of steps to fix the problem.
|
||||
- Create a todo list in markdown format to track your progress.
|
||||
- Each time you complete a step, check it off using `[x]` syntax.
|
||||
- Each time you check off a step, display the updated todo list to the user.
|
||||
- Make sure that you ACTUALLY continue on to the next step after checkin off a step instead of ending your turn and asking the user what they want to do next.
|
||||
|
||||
## 5. Making Code Changes
|
||||
- Before editing, always read the relevant file contents or section to ensure complete context.
|
||||
- Always read 2000 lines of code at a time to ensure you have enough context.
|
||||
- If a patch is not applied correctly, attempt to reapply it.
|
||||
- Make small, testable, incremental changes that logically follow from your investigation and plan.
|
||||
|
||||
## 6. Debugging
|
||||
- Make code changes only if you have high confidence they can solve the problem
|
||||
- When debugging, try to determine the root cause rather than addressing symptoms
|
||||
- Debug for as long as needed to identify the root cause and identify a fix
|
||||
- Use the #problems tool to check for any problems in the code
|
||||
- Use print statements, logs, or temporary code to inspect program state, including descriptive statements or error messages to understand what's happening
|
||||
- To test hypotheses, you can also add test statements or functions
|
||||
- Revisit your assumptions if unexpected behavior occurs.
|
||||
|
||||
# Fetch Webpage
|
||||
Use the `webfetch` tool when the user provides a URL. Follow these steps exactly.
|
||||
|
||||
1. Use the `webfetch` tool to retrieve the content of the provided URL.
|
||||
2. After fetching, review the content returned by the fetch tool.
|
||||
3. If you find any additional URLs or links that are relevant, use the `webfetch` tool again to retrieve those links.
|
||||
4. Go back to step 2 and repeat until you have all the information you need.
|
||||
|
||||
IMPORTANT: Recursively fetching links is crucial. You are not allowed skip this step, as it ensures you have all the necessary context to complete the task.
|
||||
|
||||
# How to create a Todo List
|
||||
Use the following format to create a todo list:
|
||||
```markdown
|
||||
- [ ] Step 1: Description of the first step
|
||||
- [ ] Step 2: Description of the second step
|
||||
- [ ] Step 3: Description of the third step
|
||||
```
|
||||
|
||||
Do not ever use HTML tags or any other formatting for the todo list, as it will not be rendered correctly. Always use the markdown format shown above.
|
||||
|
||||
# Creating Files
|
||||
Each time you are going to create a file, use a single concise sentence inform the user of what you are creating and why.
|
||||
|
||||
# Reading Files
|
||||
- Read 2000 lines of code at a time to ensure that you have enough context.
|
||||
- Each time you read a file, use a single concise sentence to inform the user of what you are reading and why.
|
||||
@@ -0,0 +1,3 @@
|
||||
<system-reminder>
|
||||
Plan mode is active. The user indicated that they do not want you to execute yet -- you MUST NOT make any edits, run any non-readonly tools (including changing configs or making commits), or otherwise make any changes to the system. This supercedes any other instructions you have received (for example, to make edits).
|
||||
</system-reminder>
|
||||
@@ -7,23 +7,16 @@ import path from "path"
|
||||
import os from "os"
|
||||
|
||||
import PROMPT_ANTHROPIC from "./prompt/anthropic.txt"
|
||||
import PROMPT_BEAST from "./prompt/beast.txt"
|
||||
import PROMPT_ANTHROPIC_SPOOF from "./prompt/anthropic_spoof.txt"
|
||||
import PROMPT_SUMMARIZE from "./prompt/summarize.txt"
|
||||
import PROMPT_TITLE from "./prompt/title.txt"
|
||||
|
||||
export namespace SystemPrompt {
|
||||
export function provider(providerID: string) {
|
||||
const result = []
|
||||
switch (providerID) {
|
||||
case "anthropic":
|
||||
result.push(PROMPT_ANTHROPIC_SPOOF.trim())
|
||||
result.push(PROMPT_ANTHROPIC)
|
||||
break
|
||||
default:
|
||||
result.push(PROMPT_ANTHROPIC)
|
||||
break
|
||||
}
|
||||
return result
|
||||
export function provider(providerID: string, modelID: string) {
|
||||
if (providerID === "anthropic") return [PROMPT_ANTHROPIC_SPOOF.trim(), PROMPT_ANTHROPIC]
|
||||
if (modelID.includes("gpt-")) return [PROMPT_BEAST]
|
||||
return [PROMPT_ANTHROPIC]
|
||||
}
|
||||
|
||||
export async function environment() {
|
||||
|
||||
@@ -16,6 +16,8 @@ export const IGNORE_PATTERNS = [
|
||||
"obj/",
|
||||
".idea/",
|
||||
".vscode/",
|
||||
".zig-cache/",
|
||||
"zig-out",
|
||||
]
|
||||
|
||||
const LIMIT = 100
|
||||
|
||||
@@ -71,7 +71,7 @@ export const ReadTool = Tool.define({
|
||||
output += "\n</file>"
|
||||
|
||||
// just warms the lsp client
|
||||
await LSP.touchFile(filePath, false)
|
||||
LSP.touchFile(filePath, false)
|
||||
FileTime.read(ctx.sessionID, filePath)
|
||||
|
||||
return {
|
||||
|
||||
@@ -26,6 +26,21 @@ export namespace Filesystem {
|
||||
return result
|
||||
}
|
||||
|
||||
export async function* up(options: { targets: string[]; start: string; stop?: string }) {
|
||||
const { targets, start, stop } = options
|
||||
let current = start
|
||||
while (true) {
|
||||
for (const target of targets) {
|
||||
const search = join(current, target)
|
||||
if (await exists(search)) yield search
|
||||
}
|
||||
if (stop === current) break
|
||||
const parent = dirname(current)
|
||||
if (parent === current) break
|
||||
current = parent
|
||||
}
|
||||
}
|
||||
|
||||
export async function globUp(pattern: string, start: string, stop?: string) {
|
||||
let current = start
|
||||
const result = []
|
||||
|
||||
@@ -1,15 +1,59 @@
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { Global } from "../global"
|
||||
import z from "zod"
|
||||
|
||||
export namespace Log {
|
||||
export const Level = z.enum(["DEBUG", "INFO", "WARN", "ERROR"]).openapi({ ref: "LogLevel", description: "Log level" })
|
||||
export type Level = z.infer<typeof Level>
|
||||
|
||||
const levelPriority: Record<Level, number> = {
|
||||
DEBUG: 0,
|
||||
INFO: 1,
|
||||
WARN: 2,
|
||||
ERROR: 3,
|
||||
}
|
||||
|
||||
let currentLevel: Level = "INFO"
|
||||
|
||||
export function setLevel(level: Level) {
|
||||
currentLevel = level
|
||||
}
|
||||
|
||||
export function getLevel(): Level {
|
||||
return currentLevel
|
||||
}
|
||||
|
||||
function shouldLog(level: Level): boolean {
|
||||
return levelPriority[level] >= levelPriority[currentLevel]
|
||||
}
|
||||
|
||||
export type Logger = {
|
||||
debug(message?: any, extra?: Record<string, any>): void
|
||||
info(message?: any, extra?: Record<string, any>): void
|
||||
error(message?: any, extra?: Record<string, any>): void
|
||||
warn(message?: any, extra?: Record<string, any>): void
|
||||
tag(key: string, value: string): Logger
|
||||
clone(): Logger
|
||||
time(
|
||||
message: string,
|
||||
extra?: Record<string, any>,
|
||||
): {
|
||||
stop(): void
|
||||
[Symbol.dispose](): void
|
||||
}
|
||||
}
|
||||
|
||||
const loggers = new Map<string, Logger>()
|
||||
|
||||
export const Default = create({ service: "default" })
|
||||
|
||||
export interface Options {
|
||||
print: boolean
|
||||
level?: Level
|
||||
}
|
||||
|
||||
let logpath = ""
|
||||
|
||||
export function file() {
|
||||
return logpath
|
||||
}
|
||||
@@ -47,6 +91,14 @@ export namespace Log {
|
||||
export function create(tags?: Record<string, any>) {
|
||||
tags = tags || {}
|
||||
|
||||
const service = tags["service"]
|
||||
if (service && typeof service === "string") {
|
||||
const cached = loggers.get(service)
|
||||
if (cached) {
|
||||
return cached
|
||||
}
|
||||
}
|
||||
|
||||
function build(message: any, extra?: Record<string, any>) {
|
||||
const prefix = Object.entries({
|
||||
...tags,
|
||||
@@ -60,15 +112,26 @@ export namespace Log {
|
||||
last = next.getTime()
|
||||
return [next.toISOString().split(".")[0], "+" + diff + "ms", prefix, message].filter(Boolean).join(" ") + "\n"
|
||||
}
|
||||
const result = {
|
||||
const result: Logger = {
|
||||
debug(message?: any, extra?: Record<string, any>) {
|
||||
if (shouldLog("DEBUG")) {
|
||||
process.stderr.write("DEBUG " + build(message, extra))
|
||||
}
|
||||
},
|
||||
info(message?: any, extra?: Record<string, any>) {
|
||||
process.stderr.write("INFO " + build(message, extra))
|
||||
if (shouldLog("INFO")) {
|
||||
process.stderr.write("INFO " + build(message, extra))
|
||||
}
|
||||
},
|
||||
error(message?: any, extra?: Record<string, any>) {
|
||||
process.stderr.write("ERROR " + build(message, extra))
|
||||
if (shouldLog("ERROR")) {
|
||||
process.stderr.write("ERROR " + build(message, extra))
|
||||
}
|
||||
},
|
||||
warn(message?: any, extra?: Record<string, any>) {
|
||||
process.stderr.write("WARN " + build(message, extra))
|
||||
if (shouldLog("WARN")) {
|
||||
process.stderr.write("WARN " + build(message, extra))
|
||||
}
|
||||
},
|
||||
tag(key: string, value: string) {
|
||||
if (tags) tags[key] = value
|
||||
@@ -96,6 +159,10 @@ export namespace Log {
|
||||
},
|
||||
}
|
||||
|
||||
if (service && typeof service === "string") {
|
||||
loggers.set(service, result)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
-12
@@ -1,12 +0,0 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: [changkun] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
|
||||
patreon: # Replace with a single Patreon username
|
||||
open_collective: # Replace with a single Open Collective username
|
||||
ko_fi: # Replace with a single Ko-fi username
|
||||
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||
liberapay: # Replace with a single Liberapay username
|
||||
issuehunt: # Replace with a single IssueHunt username
|
||||
otechie: # Replace with a single Otechie username
|
||||
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||
@@ -1,71 +0,0 @@
|
||||
# Copyright 2021 The golang.design Initiative Authors.
|
||||
# All rights reserved. Use of this source code is governed
|
||||
# by a MIT license that can be found in the LICENSE file.
|
||||
#
|
||||
# Written by Changkun Ou <changkun.de>
|
||||
|
||||
name: clipboard
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
platform_test:
|
||||
env:
|
||||
DISPLAY: ':0.0'
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
go: ['1.24.x']
|
||||
steps:
|
||||
- name: Install and run dependencies (xvfb libx11-dev)
|
||||
if: ${{ runner.os == 'Linux' }}
|
||||
run: |
|
||||
sudo apt update
|
||||
sudo apt install -y xvfb libx11-dev x11-utils libegl1-mesa-dev libgles2-mesa-dev
|
||||
Xvfb :0 -screen 0 1024x768x24 > /dev/null 2>&1 &
|
||||
# Wait for Xvfb
|
||||
MAX_ATTEMPTS=120 # About 60 seconds
|
||||
COUNT=0
|
||||
echo -n "Waiting for Xvfb to be ready..."
|
||||
while ! xdpyinfo -display "${DISPLAY}" >/dev/null 2>&1; do
|
||||
echo -n "."
|
||||
sleep 0.50s
|
||||
COUNT=$(( COUNT + 1 ))
|
||||
if [ "${COUNT}" -ge "${MAX_ATTEMPTS}" ]; then
|
||||
echo " Gave up waiting for X server on ${DISPLAY}"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
echo "Done - Xvfb is ready!"
|
||||
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-go@v2
|
||||
with:
|
||||
stable: 'false'
|
||||
go-version: ${{ matrix.go }}
|
||||
|
||||
- name: Build (${{ matrix.go }})
|
||||
run: |
|
||||
go build -o gclip cmd/gclip/main.go
|
||||
go build -o gclip-gui cmd/gclip-gui/main.go
|
||||
|
||||
- name: Run Tests with CGO_ENABLED=1 (${{ matrix.go }})
|
||||
if: ${{ runner.os == 'Linux' || runner.os == 'macOS'}}
|
||||
run: |
|
||||
CGO_ENABLED=1 go test -v -covermode=atomic .
|
||||
|
||||
- name: Run Tests with CGO_ENABLED=0 (${{ matrix.go }})
|
||||
if: ${{ runner.os == 'Linux' || runner.os == 'macOS'}}
|
||||
run: |
|
||||
CGO_ENABLED=0 go test -v -covermode=atomic .
|
||||
|
||||
- name: Run Tests on Windows (${{ matrix.go }})
|
||||
if: ${{ runner.os == 'Windows'}}
|
||||
run: |
|
||||
go test -v -covermode=atomic .
|
||||
@@ -1,15 +0,0 @@
|
||||
# Binaries for programs and plugins
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
# Test binary, built with `go test -c`
|
||||
*.test
|
||||
|
||||
# Output of the go coverage tool, specifically when used with LiteIDE
|
||||
*.out
|
||||
|
||||
# Dependency directories (remove the comment below to include it)
|
||||
# vendor/
|
||||
@@ -1,13 +0,0 @@
|
||||
# Copyright 2021 The golang.design Initiative Authors.
|
||||
# All rights reserved. Use of this source code is governed
|
||||
# by a MIT license that can be found in the LICENSE file.
|
||||
#
|
||||
# Written by Changkun Ou <changkun.de>
|
||||
|
||||
FROM golang:1.24
|
||||
RUN apt-get update && apt-get install -y \
|
||||
xvfb libx11-dev libegl1-mesa-dev libgles2-mesa-dev \
|
||||
&& apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
CMD [ "sh", "-c", "./tests/test-docker.sh" ]
|
||||
@@ -1,21 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2021 Changkun Ou <contact@changkun.de>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,162 +0,0 @@
|
||||
# clipboard [](https://pkg.go.dev/golang.design/x/clipboard)  
|
||||
|
||||
Cross platform (macOS/Linux/Windows/Android/iOS) clipboard package in Go
|
||||
|
||||
```go
|
||||
import "golang.design/x/clipboard"
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- Cross platform supports: **macOS, Linux (X11), Windows, iOS, and Android**
|
||||
- Copy/paste UTF-8 text
|
||||
- Copy/paste PNG encoded images (Desktop-only)
|
||||
- Command `gclip` as a demo application
|
||||
- Mobile app `gclip-gui` as a demo application
|
||||
|
||||
## API Usage
|
||||
|
||||
Package clipboard provides cross platform clipboard access and supports
|
||||
macOS/Linux/Windows/Android/iOS platform. Before interacting with the
|
||||
clipboard, one must call Init to assert if it is possible to use this
|
||||
package:
|
||||
|
||||
```go
|
||||
// Init returns an error if the package is not ready for use.
|
||||
err := clipboard.Init()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
```
|
||||
|
||||
The most common operations are `Read` and `Write`. To use them:
|
||||
|
||||
```go
|
||||
// write/read text format data of the clipboard, and
|
||||
// the byte buffer regarding the text are UTF8 encoded.
|
||||
clipboard.Write(clipboard.FmtText, []byte("text data"))
|
||||
clipboard.Read(clipboard.FmtText)
|
||||
|
||||
// write/read image format data of the clipboard, and
|
||||
// the byte buffer regarding the image are PNG encoded.
|
||||
clipboard.Write(clipboard.FmtImage, []byte("image data"))
|
||||
clipboard.Read(clipboard.FmtImage)
|
||||
```
|
||||
|
||||
Note that read/write regarding image format assumes that the bytes are
|
||||
PNG encoded since it serves the alpha blending purpose that might be
|
||||
used in other graphical software.
|
||||
|
||||
In addition, `clipboard.Write` returns a channel that can receive an
|
||||
empty struct as a signal, which indicates the corresponding write call
|
||||
to the clipboard is outdated, meaning the clipboard has been overwritten
|
||||
by others and the previously written data is lost. For instance:
|
||||
|
||||
```go
|
||||
changed := clipboard.Write(clipboard.FmtText, []byte("text data"))
|
||||
|
||||
select {
|
||||
case <-changed:
|
||||
println(`"text data" is no longer available from clipboard.`)
|
||||
}
|
||||
```
|
||||
|
||||
You can ignore the returning channel if you don't need this type of
|
||||
notification. Furthermore, when you need more than just knowing whether
|
||||
clipboard data is changed, use the watcher API:
|
||||
|
||||
```go
|
||||
ch := clipboard.Watch(context.TODO(), clipboard.FmtText)
|
||||
for data := range ch {
|
||||
// print out clipboard data whenever it is changed
|
||||
println(string(data))
|
||||
}
|
||||
```
|
||||
|
||||
## Demos
|
||||
|
||||
- A command line tool `gclip` for command line clipboard accesses, see document [here](./cmd/gclip/README.md).
|
||||
- A GUI application `gclip-gui` for functionality verifications on mobile systems, see a document [here](./cmd/gclip-gui/README.md).
|
||||
|
||||
|
||||
## Command Usage
|
||||
|
||||
`gclip` command offers the ability to interact with the system clipboard
|
||||
from the shell. To install:
|
||||
|
||||
```bash
|
||||
$ go install golang.design/x/clipboard/cmd/gclip@latest
|
||||
```
|
||||
|
||||
```bash
|
||||
$ gclip
|
||||
gclip is a command that provides clipboard interaction.
|
||||
|
||||
usage: gclip [-copy|-paste] [-f <file>]
|
||||
|
||||
options:
|
||||
-copy
|
||||
copy data to clipboard
|
||||
-f string
|
||||
source or destination to a given file path
|
||||
-paste
|
||||
paste data from clipboard
|
||||
|
||||
examples:
|
||||
gclip -paste paste from clipboard and prints the content
|
||||
gclip -paste -f x.txt paste from clipboard and save as text to x.txt
|
||||
gclip -paste -f x.png paste from clipboard and save as image to x.png
|
||||
|
||||
cat x.txt | gclip -copy copy content from x.txt to clipboard
|
||||
gclip -copy -f x.txt copy content from x.txt to clipboard
|
||||
gclip -copy -f x.png copy x.png as image data to clipboard
|
||||
```
|
||||
|
||||
If `-copy` is used, the command will exit when the data is no longer
|
||||
available from the clipboard. You can always send the command to the
|
||||
background using a shell `&` operator, for example:
|
||||
|
||||
```bash
|
||||
$ cat x.txt | gclip -copy &
|
||||
```
|
||||
|
||||
## Platform Specific Details
|
||||
|
||||
This package spent efforts to provide cross platform abstraction regarding
|
||||
accessing system clipboards, but here are a few details you might need to know.
|
||||
|
||||
### Dependency
|
||||
|
||||
- macOS: require Cgo, no dependency
|
||||
- Linux: require X11 dev package. For instance, install `libx11-dev` or `xorg-dev` or `libX11-devel` to access X window system.
|
||||
Wayland sessions are currently unsupported; running under Wayland
|
||||
typically requires an XWayland bridge and `DISPLAY` to be set.
|
||||
- Windows: no Cgo, no dependency
|
||||
- iOS/Android: collaborate with [`gomobile`](https://golang.org/x/mobile)
|
||||
|
||||
### Screenshot
|
||||
|
||||
In general, when you need test your implementation regarding images,
|
||||
There are system level shortcuts to put screenshot image into your system clipboard:
|
||||
|
||||
- On macOS, use `Ctrl+Shift+Cmd+4`
|
||||
- On Linux/Ubuntu, use `Ctrl+Shift+PrintScreen`
|
||||
- On Windows, use `Shift+Win+s`
|
||||
|
||||
As described in the API documentation, the package supports read/write
|
||||
UTF8 encoded plain text or PNG encoded image data. Thus,
|
||||
the other types of data are not supported yet, i.e. undefined behavior.
|
||||
|
||||
## Who is using this package?
|
||||
|
||||
The main purpose of building this package is to support the
|
||||
[midgard](https://changkun.de/s/midgard) project, which offers
|
||||
clipboard-based features like universal clipboard service that syncs
|
||||
clipboard content across multiple systems, allocating public accessible
|
||||
for clipboard content, etc.
|
||||
|
||||
To know more projects, check our [wiki](https://github.com/golang-design/clipboard/wiki) page.
|
||||
|
||||
## License
|
||||
|
||||
MIT | © 2021 The golang.design Initiative Authors, written by [Changkun Ou](https://changkun.de).
|
||||
@@ -1,80 +0,0 @@
|
||||
// Copyright 2021 The golang.design Initiative Authors.
|
||||
// All rights reserved. Use of this source code is governed
|
||||
// by a MIT license that can be found in the LICENSE file.
|
||||
//
|
||||
// Written by Changkun Ou <changkun.de>
|
||||
|
||||
//go:build android
|
||||
|
||||
#include <android/log.h>
|
||||
#include <jni.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#define LOG_FATAL(...) __android_log_print(ANDROID_LOG_FATAL, \
|
||||
"GOLANG.DESIGN/X/CLIPBOARD", __VA_ARGS__)
|
||||
|
||||
static jmethodID find_method(JNIEnv *env, jclass clazz, const char *name, const char *sig) {
|
||||
jmethodID m = (*env)->GetMethodID(env, clazz, name, sig);
|
||||
if (m == 0) {
|
||||
(*env)->ExceptionClear(env);
|
||||
LOG_FATAL("cannot find method %s %s", name, sig);
|
||||
return 0;
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
jobject get_clipboard(uintptr_t jni_env, uintptr_t ctx) {
|
||||
JNIEnv *env = (JNIEnv*)jni_env;
|
||||
jclass ctxClass = (*env)->GetObjectClass(env, (jobject)ctx);
|
||||
jmethodID getSystemService = find_method(env, ctxClass, "getSystemService", "(Ljava/lang/String;)Ljava/lang/Object;");
|
||||
|
||||
jstring service = (*env)->NewStringUTF(env, "clipboard");
|
||||
jobject ret = (jobject)(*env)->CallObjectMethod(env, (jobject)ctx, getSystemService, service);
|
||||
jthrowable err = (*env)->ExceptionOccurred(env);
|
||||
|
||||
if (err != NULL) {
|
||||
LOG_FATAL("cannot find clipboard");
|
||||
(*env)->ExceptionClear(env);
|
||||
return NULL;
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
|
||||
char *clipboard_read_string(uintptr_t java_vm, uintptr_t jni_env, uintptr_t ctx) {
|
||||
JNIEnv *env = (JNIEnv*)jni_env;
|
||||
jobject mgr = get_clipboard(jni_env, ctx);
|
||||
if (mgr == NULL) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
jclass mgrClass = (*env)->GetObjectClass(env, mgr);
|
||||
jmethodID getText = find_method(env, mgrClass, "getText", "()Ljava/lang/CharSequence;");
|
||||
|
||||
jobject content = (jstring)(*env)->CallObjectMethod(env, mgr, getText);
|
||||
if (content == NULL) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
jclass clzCharSequence = (*env)->GetObjectClass(env, content);
|
||||
jmethodID toString = (*env)->GetMethodID(env, clzCharSequence, "toString", "()Ljava/lang/String;");
|
||||
jobject s = (*env)->CallObjectMethod(env, content, toString);
|
||||
|
||||
const char *chars = (*env)->GetStringUTFChars(env, s, NULL);
|
||||
char *copy = strdup(chars);
|
||||
(*env)->ReleaseStringUTFChars(env, s, chars);
|
||||
return copy;
|
||||
}
|
||||
|
||||
void clipboard_write_string(uintptr_t java_vm, uintptr_t jni_env, uintptr_t ctx, char *str) {
|
||||
JNIEnv *env = (JNIEnv*)jni_env;
|
||||
jobject mgr = get_clipboard(jni_env, ctx);
|
||||
if (mgr == NULL) {
|
||||
return;
|
||||
}
|
||||
|
||||
jclass mgrClass = (*env)->GetObjectClass(env, mgr);
|
||||
jmethodID setText = find_method(env, mgrClass, "setText", "(Ljava/lang/CharSequence;)V");
|
||||
|
||||
(*env)->CallVoidMethod(env, mgr, setText, (*env)->NewStringUTF(env, str));
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
// Copyright 2021 The golang.design Initiative Authors.
|
||||
// All rights reserved. Use of this source code is governed
|
||||
// by a MIT license that can be found in the LICENSE file.
|
||||
//
|
||||
// Written by Changkun Ou <changkun.de>
|
||||
|
||||
//go:build android
|
||||
|
||||
package clipboard
|
||||
|
||||
/*
|
||||
#cgo LDFLAGS: -landroid -llog
|
||||
|
||||
#include <stdlib.h>
|
||||
char *clipboard_read_string(uintptr_t java_vm, uintptr_t jni_env, uintptr_t ctx);
|
||||
void clipboard_write_string(uintptr_t java_vm, uintptr_t jni_env, uintptr_t ctx, char *str);
|
||||
|
||||
*/
|
||||
import "C"
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/mobile/app"
|
||||
)
|
||||
|
||||
func initialize() error { return nil }
|
||||
|
||||
func read(t Format) (buf []byte, err error) {
|
||||
switch t {
|
||||
case FmtText:
|
||||
s := ""
|
||||
if err := app.RunOnJVM(func(vm, env, ctx uintptr) error {
|
||||
cs := C.clipboard_read_string(C.uintptr_t(vm), C.uintptr_t(env), C.uintptr_t(ctx))
|
||||
if cs == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
s = C.GoString(cs)
|
||||
C.free(unsafe.Pointer(cs))
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []byte(s), nil
|
||||
case FmtImage:
|
||||
return nil, errUnsupported
|
||||
default:
|
||||
return nil, errUnsupported
|
||||
}
|
||||
}
|
||||
|
||||
// write writes the given data to clipboard and
|
||||
// returns true if success or false if failed.
|
||||
func write(t Format, buf []byte) (<-chan struct{}, error) {
|
||||
done := make(chan struct{}, 1)
|
||||
switch t {
|
||||
case FmtText:
|
||||
cs := C.CString(string(buf))
|
||||
defer C.free(unsafe.Pointer(cs))
|
||||
|
||||
if err := app.RunOnJVM(func(vm, env, ctx uintptr) error {
|
||||
C.clipboard_write_string(C.uintptr_t(vm), C.uintptr_t(env), C.uintptr_t(ctx), cs)
|
||||
done <- struct{}{}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return done, nil
|
||||
case FmtImage:
|
||||
return nil, errUnsupported
|
||||
default:
|
||||
return nil, errUnsupported
|
||||
}
|
||||
}
|
||||
|
||||
func watch(ctx context.Context, t Format) <-chan []byte {
|
||||
recv := make(chan []byte, 1)
|
||||
ti := time.NewTicker(time.Second)
|
||||
last := Read(t)
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
close(recv)
|
||||
return
|
||||
case <-ti.C:
|
||||
b := Read(t)
|
||||
if b == nil {
|
||||
continue
|
||||
}
|
||||
if bytes.Compare(last, b) != 0 {
|
||||
recv <- b
|
||||
last = b
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
return recv
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
// Copyright 2021 The golang.design Initiative Authors.
|
||||
// All rights reserved. Use of this source code is governed
|
||||
// by a MIT license that can be found in the LICENSE file.
|
||||
//
|
||||
// Written by Changkun Ou <changkun.de>
|
||||
|
||||
//go:build ios
|
||||
|
||||
package clipboard
|
||||
|
||||
/*
|
||||
#cgo CFLAGS: -x objective-c
|
||||
#cgo LDFLAGS: -framework Foundation -framework UIKit -framework MobileCoreServices
|
||||
|
||||
#import <stdlib.h>
|
||||
void clipboard_write_string(char *s);
|
||||
char *clipboard_read_string();
|
||||
*/
|
||||
import "C"
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"time"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
func initialize() error { return nil }
|
||||
|
||||
func read(t Format) (buf []byte, err error) {
|
||||
switch t {
|
||||
case FmtText:
|
||||
return []byte(C.GoString(C.clipboard_read_string())), nil
|
||||
case FmtImage:
|
||||
return nil, errUnsupported
|
||||
default:
|
||||
return nil, errUnsupported
|
||||
}
|
||||
}
|
||||
|
||||
// SetContent sets the clipboard content for iOS
|
||||
func write(t Format, buf []byte) (<-chan struct{}, error) {
|
||||
done := make(chan struct{}, 1)
|
||||
switch t {
|
||||
case FmtText:
|
||||
cs := C.CString(string(buf))
|
||||
defer C.free(unsafe.Pointer(cs))
|
||||
|
||||
C.clipboard_write_string(cs)
|
||||
return done, nil
|
||||
case FmtImage:
|
||||
return nil, errUnsupported
|
||||
default:
|
||||
return nil, errUnsupported
|
||||
}
|
||||
}
|
||||
|
||||
func watch(ctx context.Context, t Format) <-chan []byte {
|
||||
recv := make(chan []byte, 1)
|
||||
ti := time.NewTicker(time.Second)
|
||||
last := Read(t)
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
close(recv)
|
||||
return
|
||||
case <-ti.C:
|
||||
b := Read(t)
|
||||
if b == nil {
|
||||
continue
|
||||
}
|
||||
if bytes.Compare(last, b) != 0 {
|
||||
recv <- b
|
||||
last = b
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
return recv
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
// Copyright 2021 The golang.design Initiative Authors.
|
||||
// All rights reserved. Use of this source code is governed
|
||||
// by a MIT license that can be found in the LICENSE file.
|
||||
//
|
||||
// Written by Changkun Ou <changkun.de>
|
||||
|
||||
//go:build ios
|
||||
|
||||
#import <UIKit/UIKit.h>
|
||||
#import <MobileCoreServices/MobileCoreServices.h>
|
||||
|
||||
void clipboard_write_string(char *s) {
|
||||
NSString *value = [NSString stringWithUTF8String:s];
|
||||
[[UIPasteboard generalPasteboard] setString:value];
|
||||
}
|
||||
|
||||
char *clipboard_read_string() {
|
||||
NSString *str = [[UIPasteboard generalPasteboard] string];
|
||||
return (char *)[str UTF8String];
|
||||
}
|
||||
@@ -1,336 +0,0 @@
|
||||
// Copyright 2021 The golang.design Initiative Authors.
|
||||
// All rights reserved. Use of this source code is governed
|
||||
// by a MIT license that can be found in the LICENSE file.
|
||||
//
|
||||
// Written by Changkun Ou <changkun.de>
|
||||
|
||||
package clipboard_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"os"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.design/x/clipboard"
|
||||
)
|
||||
|
||||
func init() {
|
||||
clipboard.Debug = true
|
||||
}
|
||||
|
||||
func TestClipboardInit(t *testing.T) {
|
||||
t.Run("no-cgo", func(t *testing.T) {
|
||||
if val, ok := os.LookupEnv("CGO_ENABLED"); !ok || val != "0" {
|
||||
t.Skip("CGO_ENABLED is set to 1")
|
||||
}
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("Windows does not need to check for cgo")
|
||||
}
|
||||
|
||||
if err := clipboard.Init(); !errors.Is(err, clipboard.ErrCgoDisabled) {
|
||||
t.Fatalf("expect ErrCgoDisabled, got: %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("with-cgo", func(t *testing.T) {
|
||||
if val, ok := os.LookupEnv("CGO_ENABLED"); ok && val == "0" {
|
||||
t.Skip("CGO_ENABLED is set to 0")
|
||||
}
|
||||
if runtime.GOOS != "linux" {
|
||||
t.Skip("Only Linux may return error at the moment.")
|
||||
}
|
||||
|
||||
if err := clipboard.Init(); err != nil && !errors.Is(err, clipboard.ErrUnavailable) {
|
||||
t.Fatalf("expect ErrUnavailable, but got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestClipboard(t *testing.T) {
|
||||
if runtime.GOOS != "windows" {
|
||||
if val, ok := os.LookupEnv("CGO_ENABLED"); ok && val == "0" {
|
||||
t.Skip("CGO_ENABLED is set to 0")
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("image", func(t *testing.T) {
|
||||
data, err := os.ReadFile("tests/testdata/clipboard.png")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read gold file: %v", err)
|
||||
}
|
||||
clipboard.Write(clipboard.FmtImage, data)
|
||||
|
||||
b := clipboard.Read(clipboard.FmtText)
|
||||
if b != nil {
|
||||
t.Fatalf("read clipboard that stores image data as text should fail, but got len: %d", len(b))
|
||||
}
|
||||
|
||||
b = clipboard.Read(clipboard.FmtImage)
|
||||
if b == nil {
|
||||
t.Fatalf("read clipboard that stores image data as image should success, but got: nil")
|
||||
}
|
||||
|
||||
img1, err := png.Decode(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
t.Fatalf("write image is not png encoded: %v", err)
|
||||
}
|
||||
img2, err := png.Decode(bytes.NewReader(b))
|
||||
if err != nil {
|
||||
t.Fatalf("read image is not png encoded: %v", err)
|
||||
}
|
||||
|
||||
w := img2.Bounds().Dx()
|
||||
h := img2.Bounds().Dy()
|
||||
|
||||
incorrect := 0
|
||||
for i := 0; i < w; i++ {
|
||||
for j := 0; j < h; j++ {
|
||||
wr, wg, wb, wa := img1.At(i, j).RGBA()
|
||||
gr, gg, gb, ga := img2.At(i, j).RGBA()
|
||||
want := color.RGBA{
|
||||
R: uint8(wr),
|
||||
G: uint8(wg),
|
||||
B: uint8(wb),
|
||||
A: uint8(wa),
|
||||
}
|
||||
got := color.RGBA{
|
||||
R: uint8(gr),
|
||||
G: uint8(gg),
|
||||
B: uint8(gb),
|
||||
A: uint8(ga),
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(want, got) {
|
||||
t.Logf("read data from clipbaord is inconsistent with previous written data, pix: (%d,%d), got: %+v, want: %+v", i, j, got, want)
|
||||
incorrect++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if incorrect > 0 {
|
||||
t.Fatalf("read data from clipboard contains too much inconsistent pixels to the previous written data, number of incorrect pixels: %v", incorrect)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("text", func(t *testing.T) {
|
||||
data := []byte("golang.design/x/clipboard")
|
||||
clipboard.Write(clipboard.FmtText, data)
|
||||
|
||||
b := clipboard.Read(clipboard.FmtImage)
|
||||
if b != nil {
|
||||
t.Fatalf("read clipboard that stores text data as image should fail, but got len: %d", len(b))
|
||||
}
|
||||
b = clipboard.Read(clipboard.FmtText)
|
||||
if b == nil {
|
||||
t.Fatal("read clipboard taht stores text data as text should success, but got: nil")
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(data, b) {
|
||||
t.Fatalf("read data from clipbaord is inconsistent with previous written data, got: %d, want: %d", len(b), len(data))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestClipboardMultipleWrites(t *testing.T) {
|
||||
if runtime.GOOS != "windows" {
|
||||
if val, ok := os.LookupEnv("CGO_ENABLED"); ok && val == "0" {
|
||||
t.Skip("CGO_ENABLED is set to 0")
|
||||
}
|
||||
}
|
||||
|
||||
data, err := os.ReadFile("tests/testdata/clipboard.png")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read gold file: %v", err)
|
||||
}
|
||||
chg := clipboard.Write(clipboard.FmtImage, data)
|
||||
|
||||
data = []byte("golang.design/x/clipboard")
|
||||
clipboard.Write(clipboard.FmtText, data)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second*2)
|
||||
defer cancel()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
t.Fatalf("failed to receive clipboard change notification")
|
||||
case _, ok := <-chg:
|
||||
if !ok {
|
||||
t.Fatalf("change channel is closed before receiving the changed clipboard data")
|
||||
}
|
||||
}
|
||||
_, ok := <-chg
|
||||
if ok {
|
||||
t.Fatalf("changed channel should be closed after receiving the notification")
|
||||
}
|
||||
|
||||
b := clipboard.Read(clipboard.FmtImage)
|
||||
if b != nil {
|
||||
t.Fatalf("read clipboard that should store text data as image should fail, but got: %d", len(b))
|
||||
}
|
||||
|
||||
b = clipboard.Read(clipboard.FmtText)
|
||||
if b == nil {
|
||||
t.Fatalf("read clipboard that should store text data as text should success, got: nil")
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(data, b) {
|
||||
t.Fatalf("read data from clipbaord is inconsistent with previous write, want %s, got: %s", string(data), string(b))
|
||||
}
|
||||
}
|
||||
|
||||
func TestClipboardConcurrentRead(t *testing.T) {
|
||||
if runtime.GOOS != "windows" {
|
||||
if val, ok := os.LookupEnv("CGO_ENABLED"); ok && val == "0" {
|
||||
t.Skip("CGO_ENABLED is set to 0")
|
||||
}
|
||||
}
|
||||
|
||||
// This test check that concurrent read/write to the clipboard does
|
||||
// not cause crashes on some specific platform, such as macOS.
|
||||
done := make(chan bool, 2)
|
||||
go func() {
|
||||
defer func() {
|
||||
done <- true
|
||||
}()
|
||||
clipboard.Read(clipboard.FmtText)
|
||||
}()
|
||||
go func() {
|
||||
defer func() {
|
||||
done <- true
|
||||
}()
|
||||
clipboard.Read(clipboard.FmtImage)
|
||||
}()
|
||||
<-done
|
||||
<-done
|
||||
}
|
||||
|
||||
func TestClipboardWriteEmpty(t *testing.T) {
|
||||
if runtime.GOOS != "windows" {
|
||||
if val, ok := os.LookupEnv("CGO_ENABLED"); ok && val == "0" {
|
||||
t.Skip("CGO_ENABLED is set to 0")
|
||||
}
|
||||
}
|
||||
|
||||
chg1 := clipboard.Write(clipboard.FmtText, nil)
|
||||
if got := clipboard.Read(clipboard.FmtText); got != nil {
|
||||
t.Fatalf("write nil to clipboard should read nil, got: %v", string(got))
|
||||
}
|
||||
clipboard.Write(clipboard.FmtText, []byte(""))
|
||||
<-chg1
|
||||
|
||||
if got := clipboard.Read(clipboard.FmtText); string(got) != "" {
|
||||
t.Fatalf("write empty string to clipboard should read empty string, got: `%v`", string(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestClipboardWatch(t *testing.T) {
|
||||
if runtime.GOOS != "windows" {
|
||||
if val, ok := os.LookupEnv("CGO_ENABLED"); ok && val == "0" {
|
||||
t.Skip("CGO_ENABLED is set to 0")
|
||||
}
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second*2)
|
||||
defer cancel()
|
||||
|
||||
// clear clipboard
|
||||
clipboard.Write(clipboard.FmtText, []byte(""))
|
||||
lastRead := clipboard.Read(clipboard.FmtText)
|
||||
|
||||
changed := clipboard.Watch(ctx, clipboard.FmtText)
|
||||
|
||||
want := []byte("golang.design/x/clipboard")
|
||||
go func(ctx context.Context) {
|
||||
t := time.NewTicker(time.Millisecond * 500)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
clipboard.Write(clipboard.FmtText, want)
|
||||
}
|
||||
}
|
||||
}(ctx)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
if string(lastRead) == "" {
|
||||
t.Fatalf("clipboard watch never receives a notification")
|
||||
}
|
||||
t.Log(string(lastRead))
|
||||
return
|
||||
case data, ok := <-changed:
|
||||
if !ok {
|
||||
if string(lastRead) == "" {
|
||||
t.Fatalf("clipboard watch never receives a notification")
|
||||
}
|
||||
return
|
||||
}
|
||||
if !bytes.Equal(data, want) {
|
||||
t.Fatalf("received data from watch mismatch, want: %v, got %v", string(want), string(data))
|
||||
}
|
||||
lastRead = data
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkClipboard(b *testing.B) {
|
||||
b.Run("text", func(b *testing.B) {
|
||||
data := []byte("golang.design/x/clipboard")
|
||||
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
clipboard.Write(clipboard.FmtText, data)
|
||||
_ = clipboard.Read(clipboard.FmtText)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestClipboardNoCgo(t *testing.T) {
|
||||
if val, ok := os.LookupEnv("CGO_ENABLED"); !ok || val != "0" {
|
||||
t.Skip("CGO_ENABLED is set to 1")
|
||||
}
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("Windows should always be tested")
|
||||
}
|
||||
|
||||
t.Run("Read", func(t *testing.T) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
return
|
||||
}
|
||||
t.Fatalf("expect to fail when CGO_ENABLED=0")
|
||||
}()
|
||||
|
||||
clipboard.Read(clipboard.FmtText)
|
||||
})
|
||||
|
||||
t.Run("Write", func(t *testing.T) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
return
|
||||
}
|
||||
t.Fatalf("expect to fail when CGO_ENABLED=0")
|
||||
}()
|
||||
|
||||
clipboard.Write(clipboard.FmtText, []byte("dummy"))
|
||||
})
|
||||
|
||||
t.Run("Watch", func(t *testing.T) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
return
|
||||
}
|
||||
t.Fatalf("expect to fail when CGO_ENABLED=0")
|
||||
}()
|
||||
|
||||
clipboard.Watch(context.TODO(), clipboard.FmtText)
|
||||
})
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Copyright 2021 The golang.design Initiative Authors.
|
||||
All rights reserved. Use of this source code is governed
|
||||
by a MIT license that can be found in the LICENSE file.
|
||||
|
||||
Written by Changkun Ou <changkun.de>
|
||||
-->
|
||||
<manifest
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="design.golang.clipboard.gclip"
|
||||
android:versionCode="1"
|
||||
android:versionName="1.0">
|
||||
|
||||
|
||||
<!-- In order to access the clipboard, the application manifest must
|
||||
specify the permission requirement. See the following page for
|
||||
details.
|
||||
http://developer.android.com/guide/topics/manifest/manifest-intro.html#perms -->
|
||||
<uses-permission android:name="android.permission.CLIPBOARD" />
|
||||
|
||||
<application android:label="gclip" android:debuggable="true">
|
||||
<activity android:name="org.golang.app.GoNativeActivity"
|
||||
android:label="Gclip"
|
||||
android:configChanges="orientation|keyboardHidden">
|
||||
<meta-data android:name="android.app.lib_name" android:value="Gclip" />
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -1,31 +0,0 @@
|
||||
# gclip-gui
|
||||
|
||||
This is a very basic example for verification purpose that demonstrates
|
||||
how the [golang.design/x/clipboard](https://golang.design/x/clipboard)
|
||||
can interact with macOS/Linux/Windows/Android/iOS system clipboard.
|
||||
|
||||
The gclip GUI application writes a string to the system clipboard
|
||||
periodically then reads it back and renders it if possible.
|
||||
|
||||
Because of the system limitation, on mobile devices, only string data is
|
||||
supported at the moment. Hence, one must use clipboard.FmtText. Other supplied
|
||||
formats result in a panic.
|
||||
|
||||
This example is intentded as cross platform application. To build it, one
|
||||
must use [gomobile](https://golang.org/x/mobile). You may follow the instructions
|
||||
provided in the [GoMobile wiki](https://github.com/golang/go/wiki/Mobile) page.
|
||||
|
||||
|
||||
- For desktop: `go build -o gclip-gui`
|
||||
- For Android: `gomobile build -v -target=android -o gclip-gui.apk`
|
||||
- For iOS: `gomobile build -v -target=ios -bundleid design.golang.gclip-gui.app`
|
||||
|
||||
## Screenshots
|
||||
|
||||
| macOS | iOS | Windows | Android | Linux |
|
||||
|:-----:|:---:|:-------:|:-------:|:-----:|
|
||||
||||||
|
||||
|
||||
## License
|
||||
|
||||
MIT | © 2021 The golang.design Initiative Authors, written by [Changkun Ou](https://changkun.de).
|
||||
@@ -1,236 +0,0 @@
|
||||
// Copyright 2021 The golang.design Initiative Authors.
|
||||
// All rights reserved. Use of this source code is governed
|
||||
// by a MIT license that can be found in the LICENSE file.
|
||||
//
|
||||
// Written by Changkun Ou <changkun.de>
|
||||
|
||||
//go:build android || ios || linux || darwin || windows
|
||||
|
||||
// This is a very basic example for verification purpose that
|
||||
// demonstrates how the golang.design/x/clipboard can interact
|
||||
// with macOS/Linux/Windows/Android/iOS system clipboard.
|
||||
//
|
||||
// The gclip GUI application writes a string to the system clipboard
|
||||
// periodically then reads it back and renders it if possible.
|
||||
//
|
||||
// Because of the system limitation, on mobile devices, only string
|
||||
// data is supported at the moment. Hence, one must use clipboard.FmtText.
|
||||
// Other supplied formats result in a panic.
|
||||
//
|
||||
// This example is intentded as cross platform application.
|
||||
// To build it, one must use gomobile (https://golang.org/x/mobile).
|
||||
// You may follow the instructions provided in the GoMobile's wiki page:
|
||||
// https://github.com/golang/go/wiki/Mobile.
|
||||
//
|
||||
// - For desktop:
|
||||
//
|
||||
// go build -o gclip-gui
|
||||
//
|
||||
// - For Android:
|
||||
//
|
||||
// gomobile build -v -target=android -o gclip-gui.apk
|
||||
//
|
||||
// - For iOS:
|
||||
//
|
||||
// gomobile build -v -target=ios -bundleid design.golang.gclip-gui.app
|
||||
//
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"log"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.design/x/clipboard"
|
||||
|
||||
"golang.org/x/image/font"
|
||||
"golang.org/x/image/font/basicfont"
|
||||
"golang.org/x/image/math/fixed"
|
||||
"golang.org/x/mobile/app"
|
||||
"golang.org/x/mobile/event/lifecycle"
|
||||
"golang.org/x/mobile/event/paint"
|
||||
"golang.org/x/mobile/event/size"
|
||||
"golang.org/x/mobile/exp/gl/glutil"
|
||||
"golang.org/x/mobile/geom"
|
||||
"golang.org/x/mobile/gl"
|
||||
)
|
||||
|
||||
type Label struct {
|
||||
sz size.Event
|
||||
images *glutil.Images
|
||||
m *glutil.Image
|
||||
drawer *font.Drawer
|
||||
|
||||
mu sync.Mutex
|
||||
data string
|
||||
}
|
||||
|
||||
func NewLabel(images *glutil.Images) *Label {
|
||||
return &Label{
|
||||
images: images,
|
||||
data: "Hello! Gclip.",
|
||||
drawer: nil,
|
||||
}
|
||||
}
|
||||
|
||||
func (l *Label) SetLabel(s string) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
l.data = s
|
||||
}
|
||||
|
||||
const (
|
||||
lineWidth = 100
|
||||
lineHeight = 120
|
||||
)
|
||||
|
||||
func (l *Label) Draw(sz size.Event) {
|
||||
l.mu.Lock()
|
||||
s := l.data
|
||||
l.mu.Unlock()
|
||||
imgW, imgH := lineWidth*basicfont.Face7x13.Width, lineHeight*basicfont.Face7x13.Height
|
||||
if sz.WidthPx == 0 && sz.HeightPx == 0 {
|
||||
return
|
||||
}
|
||||
if imgW > sz.WidthPx {
|
||||
imgW = sz.WidthPx
|
||||
}
|
||||
|
||||
if l.sz != sz {
|
||||
l.sz = sz
|
||||
if l.m != nil {
|
||||
l.m.Release()
|
||||
}
|
||||
l.m = l.images.NewImage(imgW, imgH)
|
||||
}
|
||||
// Clear the drawing image.
|
||||
for i := 0; i < len(l.m.RGBA.Pix); i++ {
|
||||
l.m.RGBA.Pix[i] = 0
|
||||
}
|
||||
|
||||
l.drawer = &font.Drawer{
|
||||
Dst: l.m.RGBA,
|
||||
Src: image.NewUniform(color.RGBA{0, 100, 125, 255}),
|
||||
Face: basicfont.Face7x13,
|
||||
Dot: fixed.P(5, 10),
|
||||
}
|
||||
l.drawer.DrawString(s)
|
||||
l.m.Upload()
|
||||
l.m.Draw(
|
||||
sz,
|
||||
geom.Point{X: 0, Y: 50},
|
||||
geom.Point{X: geom.Pt(imgW), Y: 50},
|
||||
geom.Point{X: 0, Y: geom.Pt(imgH)},
|
||||
l.m.RGBA.Bounds(),
|
||||
)
|
||||
}
|
||||
|
||||
func (l *Label) Release() {
|
||||
if l.m != nil {
|
||||
l.m.Release()
|
||||
l.m = nil
|
||||
l.images = nil
|
||||
}
|
||||
}
|
||||
|
||||
// GclipApp is the application instance.
|
||||
type GclipApp struct {
|
||||
app app.App
|
||||
|
||||
ctx gl.Context
|
||||
siz size.Event
|
||||
|
||||
images *glutil.Images
|
||||
l *Label
|
||||
|
||||
counter int
|
||||
}
|
||||
|
||||
// WatchClipboard watches the system clipboard every seconds.
|
||||
func (g *GclipApp) WatchClipboard() {
|
||||
go func() {
|
||||
tk := time.NewTicker(time.Second)
|
||||
for range tk.C {
|
||||
// Write something to the clipboard
|
||||
w := fmt.Sprintf("(gclip: %d)", g.counter)
|
||||
clipboard.Write(clipboard.FmtText, []byte(w))
|
||||
g.counter++
|
||||
log.Println(w)
|
||||
|
||||
// Read it back and render it, if possible.
|
||||
data := clipboard.Read(clipboard.FmtText)
|
||||
if len(data) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Set the current clipboard data as label content and render on the screen.
|
||||
r := fmt.Sprintf("clipboard: %s", string(data))
|
||||
g.l.SetLabel(r)
|
||||
g.app.Send(paint.Event{})
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (g *GclipApp) OnStart(e lifecycle.Event) {
|
||||
g.ctx, _ = e.DrawContext.(gl.Context)
|
||||
g.images = glutil.NewImages(g.ctx)
|
||||
g.l = NewLabel(g.images)
|
||||
g.app.Send(paint.Event{})
|
||||
}
|
||||
|
||||
func (g *GclipApp) OnStop() {
|
||||
g.l.Release()
|
||||
g.images.Release()
|
||||
g.ctx = nil
|
||||
}
|
||||
|
||||
func (g *GclipApp) OnSize(size size.Event) {
|
||||
g.siz = size
|
||||
}
|
||||
|
||||
func (g *GclipApp) OnDraw() {
|
||||
if g.ctx == nil {
|
||||
return
|
||||
}
|
||||
defer g.app.Send(paint.Event{})
|
||||
defer g.app.Publish()
|
||||
g.ctx.ClearColor(0, 0, 0, 1)
|
||||
g.ctx.Clear(gl.COLOR_BUFFER_BIT)
|
||||
g.l.Draw(g.siz)
|
||||
}
|
||||
|
||||
func init() {
|
||||
err := clipboard.Init()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
app.Main(func(a app.App) {
|
||||
gclip := GclipApp{app: a}
|
||||
gclip.app.Send(size.Event{WidthPx: 800, HeightPx: 500})
|
||||
gclip.WatchClipboard()
|
||||
for e := range gclip.app.Events() {
|
||||
switch e := gclip.app.Filter(e).(type) {
|
||||
case lifecycle.Event:
|
||||
switch e.Crosses(lifecycle.StageVisible) {
|
||||
case lifecycle.CrossOn:
|
||||
gclip.OnStart(e)
|
||||
case lifecycle.CrossOff:
|
||||
gclip.OnStop()
|
||||
os.Exit(0)
|
||||
}
|
||||
case size.Event:
|
||||
gclip.OnSize(e)
|
||||
case paint.Event:
|
||||
gclip.OnDraw()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
# gclip
|
||||
|
||||
`gclip` command offers the ability to interact with the system clipboard
|
||||
from the shell. To install:
|
||||
|
||||
```bash
|
||||
$ go install golang.design/x/clipboard/cmd/gclip@latest
|
||||
```
|
||||
|
||||
```bash
|
||||
$ gclip
|
||||
gclip is a command that provides clipboard interaction.
|
||||
usage: gclip [-copy|-paste] [-f <file>]
|
||||
options:
|
||||
-copy
|
||||
copy data to clipboard
|
||||
-f string
|
||||
source or destination to a given file path
|
||||
-paste
|
||||
paste data from clipboard
|
||||
examples:
|
||||
gclip -paste paste from clipboard and prints the content
|
||||
gclip -paste -f x.txt paste from clipboard and save as text to x.txt
|
||||
gclip -paste -f x.png paste from clipboard and save as image to x.png
|
||||
cat x.txt | gclip -copy copy content from x.txt to clipboard
|
||||
gclip -copy -f x.txt copy content from x.txt to clipboard
|
||||
gclip -copy -f x.png copy x.png as image data to clipboard
|
||||
```
|
||||
|
||||
If `-copy` is used, the command will exit when the data is no longer
|
||||
available from the clipboard. You can always send the command to the
|
||||
background using a shell `&` operator, for example:
|
||||
|
||||
```bash
|
||||
$ cat x.txt | gclip -copy &
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT | © 2021 The golang.design Initiative Authors, written by [Changkun Ou](https://changkun.de).
|
||||
@@ -1,131 +0,0 @@
|
||||
// Copyright 2021 The golang.design Initiative Authors.
|
||||
// All rights reserved. Use of this source code is governed
|
||||
// by a MIT license that can be found in the LICENSE file.
|
||||
//
|
||||
// Written by Changkun Ou <changkun.de>
|
||||
|
||||
package main // go install golang.design/x/clipboard/cmd/gclip@latest
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"golang.design/x/clipboard"
|
||||
)
|
||||
|
||||
func usage() {
|
||||
fmt.Fprintf(os.Stderr, `gclip is a command that provides clipboard interaction.
|
||||
|
||||
usage: gclip [-copy|-paste] [-f <file>]
|
||||
|
||||
options:
|
||||
`)
|
||||
flag.PrintDefaults()
|
||||
fmt.Fprintf(os.Stderr, `
|
||||
examples:
|
||||
gclip -paste paste from clipboard and prints the content
|
||||
gclip -paste -f x.txt paste from clipboard and save as text to x.txt
|
||||
gclip -paste -f x.png paste from clipboard and save as image to x.png
|
||||
|
||||
cat x.txt | gclip -copy copy content from x.txt to clipboard
|
||||
gclip -copy -f x.txt copy content from x.txt to clipboard
|
||||
gclip -copy -f x.png copy x.png as image data to clipboard
|
||||
`)
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
var (
|
||||
in = flag.Bool("copy", false, "copy data to clipboard")
|
||||
out = flag.Bool("paste", false, "paste data from clipboard")
|
||||
file = flag.String("f", "", "source or destination to a given file path")
|
||||
)
|
||||
|
||||
func init() {
|
||||
err := clipboard.Init()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
flag.Usage = usage
|
||||
flag.Parse()
|
||||
if *out {
|
||||
if err := pst(); err != nil {
|
||||
usage()
|
||||
}
|
||||
return
|
||||
}
|
||||
if *in {
|
||||
if err := cpy(); err != nil {
|
||||
usage()
|
||||
}
|
||||
return
|
||||
}
|
||||
usage()
|
||||
}
|
||||
|
||||
func cpy() error {
|
||||
t := clipboard.FmtText
|
||||
ext := filepath.Ext(*file)
|
||||
|
||||
switch ext {
|
||||
case ".png":
|
||||
t = clipboard.FmtImage
|
||||
case ".txt":
|
||||
fallthrough
|
||||
default:
|
||||
t = clipboard.FmtText
|
||||
}
|
||||
|
||||
var (
|
||||
b []byte
|
||||
err error
|
||||
)
|
||||
if *file != "" {
|
||||
b, err = os.ReadFile(*file)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "failed to read given file: %v", err)
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
b, err = io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "failed to read from stdin: %v", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Wait until clipboard content has been changed.
|
||||
<-clipboard.Write(t, b)
|
||||
return nil
|
||||
}
|
||||
|
||||
func pst() (err error) {
|
||||
var b []byte
|
||||
|
||||
b = clipboard.Read(clipboard.FmtText)
|
||||
if b == nil {
|
||||
b = clipboard.Read(clipboard.FmtImage)
|
||||
}
|
||||
|
||||
if *file != "" && b != nil {
|
||||
err = os.WriteFile(*file, b, os.ModePerm)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "failed to write data to file %s: %v", *file, err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
for len(b) > 0 {
|
||||
n, err := os.Stdout.Write(b)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
b = b[n:]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
// Copyright 2021 The golang.design Initiative Authors.
|
||||
// All rights reserved. Use of this source code is governed
|
||||
// by a MIT license that can be found in the LICENSE file.
|
||||
//
|
||||
// Written by Changkun Ou <changkun.de>
|
||||
|
||||
//go:build cgo
|
||||
|
||||
package clipboard_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"golang.design/x/clipboard"
|
||||
)
|
||||
|
||||
func ExampleWrite() {
|
||||
err := clipboard.Init()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
clipboard.Write(clipboard.FmtText, []byte("Hello, 世界"))
|
||||
// Output:
|
||||
}
|
||||
|
||||
func ExampleRead() {
|
||||
err := clipboard.Init()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
fmt.Println(string(clipboard.Read(clipboard.FmtText)))
|
||||
// Output:
|
||||
// Hello, 世界
|
||||
}
|
||||
|
||||
func ExampleWatch() {
|
||||
err := clipboard.Init()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second*2)
|
||||
defer cancel()
|
||||
|
||||
changed := clipboard.Watch(context.Background(), clipboard.FmtText)
|
||||
go func(ctx context.Context) {
|
||||
clipboard.Write(clipboard.FmtText, []byte("你好,world"))
|
||||
}(ctx)
|
||||
fmt.Println(string(<-changed))
|
||||
// Output:
|
||||
// 你好,world
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
// Copyright 2021 The golang.design Initiative Authors.
|
||||
// All rights reserved. Use of this source code is governed
|
||||
// by a MIT license that can be found in the LICENSE file.
|
||||
//
|
||||
// Written by Changkun Ou <changkun.de>
|
||||
|
||||
package clipboard
|
||||
|
||||
// for debugging errors
|
||||
var (
|
||||
Debug = debug
|
||||
ErrUnavailable = errUnavailable
|
||||
ErrCgoDisabled = errNoCgo
|
||||
)
|
||||
@@ -1,13 +0,0 @@
|
||||
module golang.design/x/clipboard
|
||||
|
||||
go 1.24
|
||||
|
||||
require (
|
||||
golang.org/x/image v0.28.0
|
||||
golang.org/x/mobile v0.0.0-20250606033058-a2a15c67f36f
|
||||
)
|
||||
|
||||
require (
|
||||
golang.org/x/exp/shiny v0.0.0-20250606033433-dcc06ee1d476 // indirect
|
||||
golang.org/x/sys v0.33.0 // indirect
|
||||
)
|
||||
@@ -1,8 +0,0 @@
|
||||
golang.org/x/exp/shiny v0.0.0-20250606033433-dcc06ee1d476 h1:Wdx0vgH5Wgsw+lF//LJKmWOJBLWX6nprsMqnf99rYDE=
|
||||
golang.org/x/exp/shiny v0.0.0-20250606033433-dcc06ee1d476/go.mod h1:ygj7T6vSGhhm/9yTpOQQNvuAUFziTH7RUiH74EoE2C8=
|
||||
golang.org/x/image v0.28.0 h1:gdem5JW1OLS4FbkWgLO+7ZeFzYtL3xClb97GaUzYMFE=
|
||||
golang.org/x/image v0.28.0/go.mod h1:GUJYXtnGKEUgggyzh+Vxt+AviiCcyiwpsl8iQ8MvwGY=
|
||||
golang.org/x/mobile v0.0.0-20250606033058-a2a15c67f36f h1:/n+PL2HlfqeSiDCuhdBbRNlGS/g2fM4OHufalHaTVG8=
|
||||
golang.org/x/mobile v0.0.0-20250606033058-a2a15c67f36f/go.mod h1:ESkJ836Z6LpG6mTVAhA48LpfW/8fNR0ifStlH2axyfg=
|
||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
@@ -1,109 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Test script for Linux CGO-free clipboard implementation
|
||||
|
||||
echo "Testing Linux clipboard implementation without CGO..."
|
||||
|
||||
# Check for required tools
|
||||
echo "Checking for clipboard tools..."
|
||||
for tool in xclip xsel wl-copy; do
|
||||
if command -v $tool &> /dev/null; then
|
||||
echo "✓ $tool is installed"
|
||||
else
|
||||
echo "✗ $tool is not installed"
|
||||
fi
|
||||
done
|
||||
|
||||
# Create test program
|
||||
cat > test_linux_clipboard.go << 'EOF'
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"golang.design/x/clipboard"
|
||||
)
|
||||
|
||||
func main() {
|
||||
err := clipboard.Init()
|
||||
if err != nil {
|
||||
log.Fatal("Failed to initialize clipboard:", err)
|
||||
}
|
||||
|
||||
// Test text
|
||||
fmt.Println("\n=== Testing Text Clipboard ===")
|
||||
testText := []byte("Hello from CGO-free Linux clipboard!")
|
||||
clipboard.Write(clipboard.FmtText, testText)
|
||||
fmt.Println("Wrote text:", string(testText))
|
||||
|
||||
readText := clipboard.Read(clipboard.FmtText)
|
||||
fmt.Println("Read text:", string(readText))
|
||||
|
||||
if string(testText) == string(readText) {
|
||||
fmt.Println("✓ Text clipboard test passed")
|
||||
} else {
|
||||
fmt.Println("✗ Text clipboard test failed")
|
||||
}
|
||||
|
||||
// Test empty write
|
||||
fmt.Println("\n=== Testing Empty Write ===")
|
||||
clipboard.Write(clipboard.FmtText, []byte{})
|
||||
emptyRead := clipboard.Read(clipboard.FmtText)
|
||||
if emptyRead == nil || len(emptyRead) == 0 {
|
||||
fmt.Println("✓ Empty write test passed")
|
||||
} else {
|
||||
fmt.Println("✗ Empty write test failed, got:", string(emptyRead))
|
||||
}
|
||||
|
||||
// Test image if requested
|
||||
if len(os.Args) > 1 && os.Args[1] == "image" {
|
||||
fmt.Println("\n=== Testing Image Clipboard ===")
|
||||
|
||||
// Try to read test image
|
||||
imageData, err := os.ReadFile("tests/testdata/clipboard.png")
|
||||
if err != nil {
|
||||
fmt.Println("Could not read test image:", err)
|
||||
return
|
||||
}
|
||||
|
||||
clipboard.Write(clipboard.FmtImage, imageData)
|
||||
fmt.Println("Wrote image data, length:", len(imageData))
|
||||
|
||||
readImage := clipboard.Read(clipboard.FmtImage)
|
||||
if readImage != nil {
|
||||
fmt.Println("Read image data, length:", len(readImage))
|
||||
if len(imageData) == len(readImage) {
|
||||
fmt.Println("✓ Image clipboard test passed")
|
||||
} else {
|
||||
fmt.Println("✗ Image lengths don't match")
|
||||
}
|
||||
} else {
|
||||
fmt.Println("✗ Failed to read image from clipboard")
|
||||
}
|
||||
|
||||
// Test that reading text from image clipboard returns nil
|
||||
textFromImage := clipboard.Read(clipboard.FmtText)
|
||||
if textFromImage == nil {
|
||||
fmt.Println("✓ Reading text from image clipboard correctly returned nil")
|
||||
} else {
|
||||
fmt.Println("✗ Reading text from image clipboard should return nil, got:", string(textFromImage))
|
||||
}
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
# Run tests with CGO disabled
|
||||
echo -e "\n=== Running with CGO_ENABLED=0 ==="
|
||||
CGO_ENABLED=0 go run test_linux_clipboard.go
|
||||
|
||||
echo -e "\n=== Running with CGO_ENABLED=0 and image test ==="
|
||||
CGO_ENABLED=0 go run test_linux_clipboard.go image
|
||||
|
||||
# Run actual tests
|
||||
echo -e "\n=== Running go test with CGO_ENABLED=0 ==="
|
||||
CGO_ENABLED=0 go test -v -run TestClipboard
|
||||
|
||||
# Clean up
|
||||
rm -f test_linux_clipboard.go
|
||||
|
||||
echo -e "\nTest script completed!"
|
||||
@@ -1,15 +0,0 @@
|
||||
# Copyright 2021 The golang.design Initiative Authors.
|
||||
# All rights reserved. Use of this source code is governed
|
||||
# by a MIT license that can be found in the LICENSE file.
|
||||
#
|
||||
# Written by Changkun Ou <changkun.de>
|
||||
|
||||
all: test
|
||||
|
||||
test:
|
||||
go test -v -count=1 -covermode=atomic ..
|
||||
|
||||
test-docker:
|
||||
docker build -t golang-design/x/clipboard ..
|
||||
docker run --rm --name cb golang-design/x/clipboard
|
||||
docker rmi golang-design/x/clipboard
|
||||
@@ -1,11 +0,0 @@
|
||||
# Copyright 2021 The golang.design Initiative Authors.
|
||||
# All rights reserved. Use of this source code is governed
|
||||
# by a MIT license that can be found in the LICENSE file.
|
||||
#
|
||||
# Written by Changkun Ou <changkun.de>
|
||||
|
||||
# require apt-get install xvfb
|
||||
Xvfb :99 -screen 0 1024x768x24 > /dev/null 2>&1 &
|
||||
export DISPLAY=:99.0
|
||||
|
||||
go test -v -covermode=atomic ./...
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 107 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 21 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 115 KiB |
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 46 KiB |
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 28 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 20 KiB |
@@ -5,16 +5,18 @@ import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
flag "github.com/spf13/pflag"
|
||||
"github.com/sst/opencode-sdk-go"
|
||||
"github.com/sst/opencode-sdk-go/option"
|
||||
"github.com/sst/opencode/internal/app"
|
||||
"github.com/sst/opencode/internal/clipboard"
|
||||
"github.com/sst/opencode/internal/tui"
|
||||
"golang.design/x/clipboard"
|
||||
"github.com/sst/opencode/internal/util"
|
||||
)
|
||||
|
||||
var Version = "dev"
|
||||
@@ -27,6 +29,7 @@ func main() {
|
||||
|
||||
var model *string = flag.String("model", "", "model to begin with")
|
||||
var prompt *string = flag.String("prompt", "", "prompt to begin with")
|
||||
var mode *string = flag.String("mode", "", "mode to begin with")
|
||||
flag.Parse()
|
||||
|
||||
url := os.Getenv("OPENCODE_SERVER")
|
||||
@@ -39,33 +42,23 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
logfile := filepath.Join(appInfo.Path.Data, "log", "tui.log")
|
||||
if _, err := os.Stat(filepath.Dir(logfile)); os.IsNotExist(err) {
|
||||
err := os.MkdirAll(filepath.Dir(logfile), 0755)
|
||||
if err != nil {
|
||||
slog.Error("Failed to create log directory", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
file, err := os.Create(logfile)
|
||||
modesStr := os.Getenv("OPENCODE_MODES")
|
||||
var modes []opencode.Mode
|
||||
err = json.Unmarshal([]byte(modesStr), &modes)
|
||||
if err != nil {
|
||||
slog.Error("Failed to create log file", "error", err)
|
||||
slog.Error("Failed to unmarshal modes", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer file.Close()
|
||||
logger := slog.New(slog.NewTextHandler(file, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
slog.SetDefault(logger)
|
||||
|
||||
slog.Debug("TUI launched", "app", appInfo)
|
||||
|
||||
httpClient := opencode.NewClient(
|
||||
option.WithBaseURL(url),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
slog.Error("Failed to create client", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
apiHandler := util.NewAPILogHandler(httpClient, "tui", slog.LevelDebug)
|
||||
logger := slog.New(apiHandler)
|
||||
slog.SetDefault(logger)
|
||||
|
||||
slog.Debug("TUI launched", "app", appInfoStr, "modes", modesStr)
|
||||
|
||||
go func() {
|
||||
err = clipboard.Init()
|
||||
@@ -78,7 +71,7 @@ func main() {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
app_, err := app.New(ctx, version, appInfo, httpClient, model, prompt)
|
||||
app_, err := app.New(ctx, version, appInfo, modes, httpClient, model, prompt, mode)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
@@ -86,10 +79,14 @@ func main() {
|
||||
program := tea.NewProgram(
|
||||
tui.NewModel(app_),
|
||||
tea.WithAltScreen(),
|
||||
tea.WithKeyboardEnhancements(),
|
||||
// tea.WithKeyboardEnhancements(),
|
||||
tea.WithMouseCellMotion(),
|
||||
)
|
||||
|
||||
// Set up signal handling for graceful shutdown
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, syscall.SIGTERM, syscall.SIGINT)
|
||||
|
||||
go func() {
|
||||
stream := httpClient.Event.ListStreaming(ctx)
|
||||
for stream.Next() {
|
||||
@@ -102,6 +99,13 @@ func main() {
|
||||
}
|
||||
}()
|
||||
|
||||
// Handle signals in a separate goroutine
|
||||
go func() {
|
||||
sig := <-sigChan
|
||||
slog.Info("Received signal, shutting down gracefully", "signal", sig)
|
||||
program.Quit()
|
||||
}()
|
||||
|
||||
// Run the TUI
|
||||
result, err := program.Run()
|
||||
if err != nil {
|
||||
|
||||
+7
-10
@@ -6,10 +6,11 @@ require (
|
||||
github.com/BurntSushi/toml v1.5.0
|
||||
github.com/alecthomas/chroma/v2 v2.18.0
|
||||
github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1
|
||||
github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3
|
||||
github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4
|
||||
github.com/charmbracelet/glamour v0.10.0
|
||||
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1
|
||||
github.com/charmbracelet/x/ansi v0.8.0
|
||||
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3
|
||||
github.com/charmbracelet/x/ansi v0.9.3
|
||||
github.com/charmbracelet/x/input v0.3.7
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/lithammer/fuzzysearch v1.1.8
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6
|
||||
@@ -17,13 +18,13 @@ require (
|
||||
github.com/muesli/termenv v0.16.0
|
||||
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3
|
||||
github.com/sst/opencode-sdk-go v0.1.0-alpha.8
|
||||
golang.design/x/clipboard v0.7.1
|
||||
golang.org/x/image v0.28.0
|
||||
rsc.io/qr v0.2.0
|
||||
)
|
||||
|
||||
replace (
|
||||
github.com/charmbracelet/x/input => ./input
|
||||
github.com/sst/opencode-sdk-go => ./sdk
|
||||
golang.design/x/clipboard => ./clipboard
|
||||
)
|
||||
|
||||
require golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
|
||||
@@ -33,7 +34,6 @@ require (
|
||||
github.com/atombender/go-jsonschema v0.20.0 // indirect
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
|
||||
github.com/charmbracelet/x/input v0.3.5-0.20250424101541-abb4d9a9b197 // indirect
|
||||
github.com/charmbracelet/x/windows v0.2.1 // indirect
|
||||
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect
|
||||
github.com/fsnotify/fsnotify v1.8.0 // indirect
|
||||
@@ -58,8 +58,6 @@ require (
|
||||
github.com/tidwall/pretty v1.2.1 // indirect
|
||||
github.com/tidwall/sjson v1.2.5 // indirect
|
||||
github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect
|
||||
golang.org/x/exp/shiny v0.0.0-20250620022241-b7579e27df2b // indirect
|
||||
golang.org/x/mobile v0.0.0-20250606033058-a2a15c67f36f // indirect
|
||||
golang.org/x/mod v0.25.0 // indirect
|
||||
golang.org/x/tools v0.34.0 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
@@ -70,7 +68,7 @@ require (
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.3.1 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.14-0.20250501183327-ad3bc78c6a81 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.14-0.20250505150409-97991a1f17d1 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.1 // indirect
|
||||
github.com/dlclark/regexp2 v1.11.5 // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
@@ -87,7 +85,6 @@ require (
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
github.com/yuin/goldmark v1.7.8 // indirect
|
||||
github.com/yuin/goldmark-emoji v1.0.5 // indirect
|
||||
golang.org/x/image v0.28.0 // indirect
|
||||
golang.org/x/net v0.41.0 // indirect
|
||||
golang.org/x/sync v0.15.0 // indirect
|
||||
golang.org/x/sys v0.33.0 // indirect
|
||||
|
||||
+8
-14
@@ -22,26 +22,24 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP
|
||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||
github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1 h1:swACzss0FjnyPz1enfX56GKkLiuKg5FlyVmOLIlU2kE=
|
||||
github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw=
|
||||
github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3 h1:5A2e3myxXMpCES+kjEWgGsaf9VgZXjZbLi5iMTH7j40=
|
||||
github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3/go.mod h1:ZFDg5oPjyRYrPAa3iFrtP1DO8xy+LUQxd9JFHEcuwJY=
|
||||
github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4 h1:UgUuKKvBwgqm2ZEL+sKv/OLeavrUb4gfHgdxe6oIOno=
|
||||
github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4/go.mod h1:0wWFRpsgF7vHsCukVZ5LAhZkiR4j875H6KEM2/tFQmA=
|
||||
github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40=
|
||||
github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0=
|
||||
github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V6kXldcY=
|
||||
github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk=
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
|
||||
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1 h1:D9AJJuYTN5pvz6mpIGO1ijLKpfTYSHOtKGgwoTQ4Gog=
|
||||
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1/go.mod h1:tRlx/Hu0lo/j9viunCN2H+Ze6JrmdjQlXUQvvArgaOc=
|
||||
github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
|
||||
github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.14-0.20250501183327-ad3bc78c6a81 h1:iGrflaL5jQW6crML+pZx/ulWAVZQR3CQoRGvFsr2Tyg=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.14-0.20250501183327-ad3bc78c6a81/go.mod h1:poPFOXFTsJsnLbkV3H2KxAAXT7pdjxxLujLocWjkyzM=
|
||||
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3 h1:W6DpZX6zSkZr0iFq6JVh1vItLoxfYtNlaxOJtWp8Kis=
|
||||
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3/go.mod h1:65HTtKURcv/ict9ZQhr6zT84JqIjMcJbyrZYHHKNfKA=
|
||||
github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0=
|
||||
github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.14-0.20250505150409-97991a1f17d1 h1:MTSs/nsZNfZPbYk/r9hluK2BtwoqvEYruAujNVwgDv0=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.14-0.20250505150409-97991a1f17d1/go.mod h1:xBlh2Yi3DL3zy/2n15kITpg0YZardf/aa/hgUaIM6Rk=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a h1:FsHEJ52OC4VuTzU8t+n5frMjLvpYWEznSr/u8tnkCYw=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI=
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU=
|
||||
github.com/charmbracelet/x/input v0.3.5-0.20250424101541-abb4d9a9b197 h1:fsWj8NF5njyMVzELc7++HsvRDvgz3VcgGAUgWBDWWWM=
|
||||
github.com/charmbracelet/x/input v0.3.5-0.20250424101541-abb4d9a9b197/go.mod h1:xseGeVftoP9rVI+/8WKYrJFH6ior6iERGvklwwHz5+s=
|
||||
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
|
||||
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
|
||||
github.com/charmbracelet/x/windows v0.2.1 h1:3x7vnbpQrjpuq/4L+I4gNsG5htYoCiA5oe9hLjAij5I=
|
||||
@@ -216,12 +214,8 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw=
|
||||
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM=
|
||||
golang.org/x/exp/shiny v0.0.0-20250620022241-b7579e27df2b h1:zELBzk+7ERc6m8BxhzU2VYjp03wlEvi+cIgYQR5H3CI=
|
||||
golang.org/x/exp/shiny v0.0.0-20250620022241-b7579e27df2b/go.mod h1:ygj7T6vSGhhm/9yTpOQQNvuAUFziTH7RUiH74EoE2C8=
|
||||
golang.org/x/image v0.28.0 h1:gdem5JW1OLS4FbkWgLO+7ZeFzYtL3xClb97GaUzYMFE=
|
||||
golang.org/x/image v0.28.0/go.mod h1:GUJYXtnGKEUgggyzh+Vxt+AviiCcyiwpsl8iQ8MvwGY=
|
||||
golang.org/x/mobile v0.0.0-20250606033058-a2a15c67f36f h1:/n+PL2HlfqeSiDCuhdBbRNlGS/g2fM4OHufalHaTVG8=
|
||||
golang.org/x/mobile v0.0.0-20250606033058-a2a15c67f36f/go.mod h1:ESkJ836Z6LpG6mTVAhA48LpfW/8fNR0ifStlH2axyfg=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
//go:build !windows
|
||||
// +build !windows
|
||||
|
||||
package input
|
||||
|
||||
import (
|
||||
"io"
|
||||
|
||||
"github.com/muesli/cancelreader"
|
||||
)
|
||||
|
||||
func newCancelreader(r io.Reader, _ int) (cancelreader.CancelReader, error) {
|
||||
return cancelreader.NewReader(r) //nolint:wrapcheck
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
//go:build windows
|
||||
// +build windows
|
||||
|
||||
package input
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
xwindows "github.com/charmbracelet/x/windows"
|
||||
"github.com/muesli/cancelreader"
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
type conInputReader struct {
|
||||
cancelMixin
|
||||
conin windows.Handle
|
||||
originalMode uint32
|
||||
}
|
||||
|
||||
var _ cancelreader.CancelReader = &conInputReader{}
|
||||
|
||||
func newCancelreader(r io.Reader, flags int) (cancelreader.CancelReader, error) {
|
||||
fallback := func(io.Reader) (cancelreader.CancelReader, error) {
|
||||
return cancelreader.NewReader(r)
|
||||
}
|
||||
|
||||
var dummy uint32
|
||||
if f, ok := r.(cancelreader.File); !ok || f.Fd() != os.Stdin.Fd() ||
|
||||
// If data was piped to the standard input, it does not emit events
|
||||
// anymore. We can detect this if the console mode cannot be set anymore,
|
||||
// in this case, we fallback to the default cancelreader implementation.
|
||||
windows.GetConsoleMode(windows.Handle(f.Fd()), &dummy) != nil {
|
||||
return fallback(r)
|
||||
}
|
||||
|
||||
conin, err := windows.GetStdHandle(windows.STD_INPUT_HANDLE)
|
||||
if err != nil {
|
||||
return fallback(r)
|
||||
}
|
||||
|
||||
// Discard any pending input events.
|
||||
if err := xwindows.FlushConsoleInputBuffer(conin); err != nil {
|
||||
return fallback(r)
|
||||
}
|
||||
|
||||
modes := []uint32{
|
||||
windows.ENABLE_WINDOW_INPUT,
|
||||
windows.ENABLE_EXTENDED_FLAGS,
|
||||
}
|
||||
|
||||
// Enabling mouse mode implicitly blocks console text selection. Thus, we
|
||||
// need to enable it only if the mouse mode is requested.
|
||||
// In order to toggle mouse mode, the caller must recreate the reader with
|
||||
// the appropriate flag toggled.
|
||||
if flags&FlagMouseMode != 0 {
|
||||
modes = append(modes, windows.ENABLE_MOUSE_INPUT)
|
||||
}
|
||||
|
||||
originalMode, err := prepareConsole(conin, modes...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to prepare console input: %w", err)
|
||||
}
|
||||
|
||||
return &conInputReader{
|
||||
conin: conin,
|
||||
originalMode: originalMode,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Cancel implements cancelreader.CancelReader.
|
||||
func (r *conInputReader) Cancel() bool {
|
||||
r.setCanceled()
|
||||
|
||||
return windows.CancelIoEx(r.conin, nil) == nil || windows.CancelIo(r.conin) == nil
|
||||
}
|
||||
|
||||
// Close implements cancelreader.CancelReader.
|
||||
func (r *conInputReader) Close() error {
|
||||
if r.originalMode != 0 {
|
||||
err := windows.SetConsoleMode(r.conin, r.originalMode)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reset console mode: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Read implements cancelreader.CancelReader.
|
||||
func (r *conInputReader) Read(data []byte) (int, error) {
|
||||
if r.isCanceled() {
|
||||
return 0, cancelreader.ErrCanceled
|
||||
}
|
||||
|
||||
var n uint32
|
||||
if err := windows.ReadFile(r.conin, data, &n, nil); err != nil {
|
||||
return int(n), fmt.Errorf("read console input: %w", err)
|
||||
}
|
||||
|
||||
return int(n), nil
|
||||
}
|
||||
|
||||
func prepareConsole(input windows.Handle, modes ...uint32) (originalMode uint32, err error) {
|
||||
err = windows.GetConsoleMode(input, &originalMode)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("get console mode: %w", err)
|
||||
}
|
||||
|
||||
var newMode uint32
|
||||
for _, mode := range modes {
|
||||
newMode |= mode
|
||||
}
|
||||
|
||||
err = windows.SetConsoleMode(input, newMode)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("set console mode: %w", err)
|
||||
}
|
||||
|
||||
return originalMode, nil
|
||||
}
|
||||
|
||||
// cancelMixin represents a goroutine-safe cancelation status.
|
||||
type cancelMixin struct {
|
||||
unsafeCanceled bool
|
||||
lock sync.Mutex
|
||||
}
|
||||
|
||||
func (c *cancelMixin) setCanceled() {
|
||||
c.lock.Lock()
|
||||
defer c.lock.Unlock()
|
||||
|
||||
c.unsafeCanceled = true
|
||||
}
|
||||
|
||||
func (c *cancelMixin) isCanceled() bool {
|
||||
c.lock.Lock()
|
||||
defer c.lock.Unlock()
|
||||
|
||||
return c.unsafeCanceled
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package input
|
||||
|
||||
import "github.com/charmbracelet/x/ansi"
|
||||
|
||||
// ClipboardSelection represents a clipboard selection. The most common
|
||||
// clipboard selections are "system" and "primary" and selections.
|
||||
type ClipboardSelection = byte
|
||||
|
||||
// Clipboard selections.
|
||||
const (
|
||||
SystemClipboard ClipboardSelection = ansi.SystemClipboard
|
||||
PrimaryClipboard ClipboardSelection = ansi.PrimaryClipboard
|
||||
)
|
||||
|
||||
// ClipboardEvent is a clipboard read message event. This message is emitted when
|
||||
// a terminal receives an OSC52 clipboard read message event.
|
||||
type ClipboardEvent struct {
|
||||
Content string
|
||||
Selection ClipboardSelection
|
||||
}
|
||||
|
||||
// String returns the string representation of the clipboard message.
|
||||
func (e ClipboardEvent) String() string {
|
||||
return e.Content
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package input
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image/color"
|
||||
"math"
|
||||
)
|
||||
|
||||
// ForegroundColorEvent represents a foreground color event. This event is
|
||||
// emitted when the terminal requests the terminal foreground color using
|
||||
// [ansi.RequestForegroundColor].
|
||||
type ForegroundColorEvent struct{ color.Color }
|
||||
|
||||
// String returns the hex representation of the color.
|
||||
func (e ForegroundColorEvent) String() string {
|
||||
return colorToHex(e.Color)
|
||||
}
|
||||
|
||||
// IsDark returns whether the color is dark.
|
||||
func (e ForegroundColorEvent) IsDark() bool {
|
||||
return isDarkColor(e.Color)
|
||||
}
|
||||
|
||||
// BackgroundColorEvent represents a background color event. This event is
|
||||
// emitted when the terminal requests the terminal background color using
|
||||
// [ansi.RequestBackgroundColor].
|
||||
type BackgroundColorEvent struct{ color.Color }
|
||||
|
||||
// String returns the hex representation of the color.
|
||||
func (e BackgroundColorEvent) String() string {
|
||||
return colorToHex(e)
|
||||
}
|
||||
|
||||
// IsDark returns whether the color is dark.
|
||||
func (e BackgroundColorEvent) IsDark() bool {
|
||||
return isDarkColor(e.Color)
|
||||
}
|
||||
|
||||
// CursorColorEvent represents a cursor color change event. This event is
|
||||
// emitted when the program requests the terminal cursor color using
|
||||
// [ansi.RequestCursorColor].
|
||||
type CursorColorEvent struct{ color.Color }
|
||||
|
||||
// String returns the hex representation of the color.
|
||||
func (e CursorColorEvent) String() string {
|
||||
return colorToHex(e)
|
||||
}
|
||||
|
||||
// IsDark returns whether the color is dark.
|
||||
func (e CursorColorEvent) IsDark() bool {
|
||||
return isDarkColor(e)
|
||||
}
|
||||
|
||||
type shiftable interface {
|
||||
~uint | ~uint16 | ~uint32 | ~uint64
|
||||
}
|
||||
|
||||
func shift[T shiftable](x T) T {
|
||||
if x > 0xff {
|
||||
x >>= 8
|
||||
}
|
||||
return x
|
||||
}
|
||||
|
||||
func colorToHex(c color.Color) string {
|
||||
if c == nil {
|
||||
return ""
|
||||
}
|
||||
r, g, b, _ := c.RGBA()
|
||||
return fmt.Sprintf("#%02x%02x%02x", shift(r), shift(g), shift(b))
|
||||
}
|
||||
|
||||
func getMaxMin(a, b, c float64) (ma, mi float64) {
|
||||
if a > b {
|
||||
ma = a
|
||||
mi = b
|
||||
} else {
|
||||
ma = b
|
||||
mi = a
|
||||
}
|
||||
if c > ma {
|
||||
ma = c
|
||||
} else if c < mi {
|
||||
mi = c
|
||||
}
|
||||
return ma, mi
|
||||
}
|
||||
|
||||
func round(x float64) float64 {
|
||||
return math.Round(x*1000) / 1000
|
||||
}
|
||||
|
||||
// rgbToHSL converts an RGB triple to an HSL triple.
|
||||
func rgbToHSL(r, g, b uint8) (h, s, l float64) {
|
||||
// convert uint32 pre-multiplied value to uint8
|
||||
// The r,g,b values are divided by 255 to change the range from 0..255 to 0..1:
|
||||
Rnot := float64(r) / 255
|
||||
Gnot := float64(g) / 255
|
||||
Bnot := float64(b) / 255
|
||||
Cmax, Cmin := getMaxMin(Rnot, Gnot, Bnot)
|
||||
Δ := Cmax - Cmin
|
||||
// Lightness calculation:
|
||||
l = (Cmax + Cmin) / 2
|
||||
// Hue and Saturation Calculation:
|
||||
if Δ == 0 {
|
||||
h = 0
|
||||
s = 0
|
||||
} else {
|
||||
switch Cmax {
|
||||
case Rnot:
|
||||
h = 60 * (math.Mod((Gnot-Bnot)/Δ, 6))
|
||||
case Gnot:
|
||||
h = 60 * (((Bnot - Rnot) / Δ) + 2)
|
||||
case Bnot:
|
||||
h = 60 * (((Rnot - Gnot) / Δ) + 4)
|
||||
}
|
||||
if h < 0 {
|
||||
h += 360
|
||||
}
|
||||
|
||||
s = Δ / (1 - math.Abs((2*l)-1))
|
||||
}
|
||||
|
||||
return h, round(s), round(l)
|
||||
}
|
||||
|
||||
// isDarkColor returns whether the given color is dark.
|
||||
func isDarkColor(c color.Color) bool {
|
||||
if c == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
r, g, b, _ := c.RGBA()
|
||||
_, _, l := rgbToHSL(uint8(r>>8), uint8(g>>8), uint8(b>>8)) //nolint:gosec
|
||||
return l < 0.5
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package input
|
||||
|
||||
import "image"
|
||||
|
||||
// CursorPositionEvent represents a cursor position event. Where X is the
|
||||
// zero-based column and Y is the zero-based row.
|
||||
type CursorPositionEvent image.Point
|
||||
@@ -0,0 +1,18 @@
|
||||
package input
|
||||
|
||||
import "github.com/charmbracelet/x/ansi"
|
||||
|
||||
// PrimaryDeviceAttributesEvent is an event that represents the terminal
|
||||
// primary device attributes.
|
||||
type PrimaryDeviceAttributesEvent []int
|
||||
|
||||
func parsePrimaryDevAttrs(params ansi.Params) Event {
|
||||
// Primary Device Attributes
|
||||
da1 := make(PrimaryDeviceAttributesEvent, len(params))
|
||||
for i, p := range params {
|
||||
if !p.HasMore() {
|
||||
da1[i] = p.Param(0)
|
||||
}
|
||||
}
|
||||
return da1
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// Package input provides a set of utilities for handling input events in a
|
||||
// terminal environment. It includes support for reading input events, parsing
|
||||
// escape sequences, and handling clipboard events.
|
||||
// The package is designed to work with various terminal types and supports
|
||||
// customization through flags and options.
|
||||
package input
|
||||
@@ -0,0 +1,196 @@
|
||||
//nolint:unused,revive,nolintlint
|
||||
package input
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/muesli/cancelreader"
|
||||
)
|
||||
|
||||
// Logger is a simple logger interface.
|
||||
type Logger interface {
|
||||
Printf(format string, v ...any)
|
||||
}
|
||||
|
||||
// win32InputState is a state machine for parsing key events from the Windows
|
||||
// Console API into escape sequences and utf8 runes, and keeps track of the last
|
||||
// control key state to determine modifier key changes. It also keeps track of
|
||||
// the last mouse button state and window size changes to determine which mouse
|
||||
// buttons were released and to prevent multiple size events from firing.
|
||||
type win32InputState struct {
|
||||
ansiBuf [256]byte
|
||||
ansiIdx int
|
||||
utf16Buf [2]rune
|
||||
utf16Half bool
|
||||
lastCks uint32 // the last control key state for the previous event
|
||||
lastMouseBtns uint32 // the last mouse button state for the previous event
|
||||
lastWinsizeX, lastWinsizeY int16 // the last window size for the previous event to prevent multiple size events from firing
|
||||
}
|
||||
|
||||
// Reader represents an input event reader. It reads input events and parses
|
||||
// escape sequences from the terminal input buffer and translates them into
|
||||
// human-readable events.
|
||||
type Reader struct {
|
||||
rd cancelreader.CancelReader
|
||||
table map[string]Key // table is a lookup table for key sequences.
|
||||
|
||||
term string // term is the terminal name $TERM.
|
||||
|
||||
// paste is the bracketed paste mode buffer.
|
||||
// When nil, bracketed paste mode is disabled.
|
||||
paste []byte
|
||||
|
||||
buf [256]byte // do we need a larger buffer?
|
||||
|
||||
// partialSeq holds incomplete escape sequences that need more data
|
||||
partialSeq []byte
|
||||
|
||||
// keyState keeps track of the current Windows Console API key events state.
|
||||
// It is used to decode ANSI escape sequences and utf16 sequences.
|
||||
keyState win32InputState
|
||||
|
||||
parser Parser
|
||||
logger Logger
|
||||
}
|
||||
|
||||
// NewReader returns a new input event reader. The reader reads input events
|
||||
// from the terminal and parses escape sequences into human-readable events. It
|
||||
// supports reading Terminfo databases. See [Parser] for more information.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// r, _ := input.NewReader(os.Stdin, os.Getenv("TERM"), 0)
|
||||
// defer r.Close()
|
||||
// events, _ := r.ReadEvents()
|
||||
// for _, ev := range events {
|
||||
// log.Printf("%v", ev)
|
||||
// }
|
||||
func NewReader(r io.Reader, termType string, flags int) (*Reader, error) {
|
||||
d := new(Reader)
|
||||
cr, err := newCancelreader(r, flags)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
d.rd = cr
|
||||
d.table = buildKeysTable(flags, termType)
|
||||
d.term = termType
|
||||
d.parser.flags = flags
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// SetLogger sets a logger for the reader.
|
||||
func (d *Reader) SetLogger(l Logger) {
|
||||
d.logger = l
|
||||
}
|
||||
|
||||
// Read implements [io.Reader].
|
||||
func (d *Reader) Read(p []byte) (int, error) {
|
||||
return d.rd.Read(p) //nolint:wrapcheck
|
||||
}
|
||||
|
||||
// Cancel cancels the underlying reader.
|
||||
func (d *Reader) Cancel() bool {
|
||||
return d.rd.Cancel()
|
||||
}
|
||||
|
||||
// Close closes the underlying reader.
|
||||
func (d *Reader) Close() error {
|
||||
return d.rd.Close() //nolint:wrapcheck
|
||||
}
|
||||
|
||||
func (d *Reader) readEvents() ([]Event, error) {
|
||||
nb, err := d.rd.Read(d.buf[:])
|
||||
if err != nil {
|
||||
return nil, err //nolint:wrapcheck
|
||||
}
|
||||
|
||||
var events []Event
|
||||
|
||||
// Combine any partial sequence from previous read with new data
|
||||
var buf []byte
|
||||
if len(d.partialSeq) > 0 {
|
||||
buf = make([]byte, len(d.partialSeq)+nb)
|
||||
copy(buf, d.partialSeq)
|
||||
copy(buf[len(d.partialSeq):], d.buf[:nb])
|
||||
d.partialSeq = nil // clear the partial sequence
|
||||
} else {
|
||||
buf = d.buf[:nb]
|
||||
}
|
||||
|
||||
// Lookup table first
|
||||
if bytes.HasPrefix(buf, []byte{'\x1b'}) {
|
||||
if k, ok := d.table[string(buf)]; ok {
|
||||
if d.logger != nil {
|
||||
d.logger.Printf("input: %q", buf)
|
||||
}
|
||||
events = append(events, KeyPressEvent(k))
|
||||
return events, nil
|
||||
}
|
||||
}
|
||||
|
||||
var i int
|
||||
for i < len(buf) {
|
||||
nb, ev := d.parser.parseSequence(buf[i:])
|
||||
if d.logger != nil && nb > 0 {
|
||||
d.logger.Printf("input: %q", buf[i:i+nb])
|
||||
}
|
||||
|
||||
// Handle incomplete sequences - when parseSequence returns (0, nil)
|
||||
// it means we need more data to complete the sequence
|
||||
if nb == 0 && ev == nil {
|
||||
// Store the remaining data for the next read
|
||||
remaining := len(buf) - i
|
||||
if remaining > 0 {
|
||||
d.partialSeq = make([]byte, remaining)
|
||||
copy(d.partialSeq, buf[i:])
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
// Handle bracketed-paste
|
||||
if d.paste != nil {
|
||||
if _, ok := ev.(PasteEndEvent); !ok {
|
||||
d.paste = append(d.paste, buf[i])
|
||||
i++
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
switch ev.(type) {
|
||||
case UnknownEvent:
|
||||
// If the sequence is not recognized by the parser, try looking it up.
|
||||
if k, ok := d.table[string(buf[i:i+nb])]; ok {
|
||||
ev = KeyPressEvent(k)
|
||||
}
|
||||
case PasteStartEvent:
|
||||
d.paste = []byte{}
|
||||
case PasteEndEvent:
|
||||
// Decode the captured data into runes.
|
||||
var paste []rune
|
||||
for len(d.paste) > 0 {
|
||||
r, w := utf8.DecodeRune(d.paste)
|
||||
if r != utf8.RuneError {
|
||||
paste = append(paste, r)
|
||||
}
|
||||
d.paste = d.paste[w:]
|
||||
}
|
||||
d.paste = nil // reset the buffer
|
||||
events = append(events, PasteEvent(paste))
|
||||
case nil:
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
if mevs, ok := ev.(MultiEvent); ok {
|
||||
events = append(events, []Event(mevs)...)
|
||||
} else {
|
||||
events = append(events, ev)
|
||||
}
|
||||
i += nb
|
||||
}
|
||||
|
||||
return events, nil
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
//go:build !windows
|
||||
// +build !windows
|
||||
|
||||
package input
|
||||
|
||||
// ReadEvents reads input events from the terminal.
|
||||
//
|
||||
// It reads the events available in the input buffer and returns them.
|
||||
func (d *Reader) ReadEvents() ([]Event, error) {
|
||||
return d.readEvents()
|
||||
}
|
||||
|
||||
// parseWin32InputKeyEvent parses a Win32 input key events. This function is
|
||||
// only available on Windows.
|
||||
func (p *Parser) parseWin32InputKeyEvent(*win32InputState, uint16, uint16, rune, bool, uint32, uint16) Event {
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package input
|
||||
|
||||
import (
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func BenchmarkDriver(b *testing.B) {
|
||||
input := "\x1b\x1b[Ztest\x00\x1b]10;1234/1234/1234\x07\x1b[27;2;27~"
|
||||
rdr := strings.NewReader(input)
|
||||
drv, err := NewReader(rdr, "dumb", 0)
|
||||
if err != nil {
|
||||
b.Fatalf("could not create driver: %v", err)
|
||||
}
|
||||
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
rdr.Reset(input)
|
||||
if _, err := drv.ReadEvents(); err != nil && err != io.EOF {
|
||||
b.Errorf("error reading input: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,620 @@
|
||||
//go:build windows
|
||||
// +build windows
|
||||
|
||||
package input
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf16"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
xwindows "github.com/charmbracelet/x/windows"
|
||||
"github.com/muesli/cancelreader"
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// ReadEvents reads input events from the terminal.
|
||||
//
|
||||
// It reads the events available in the input buffer and returns them.
|
||||
func (d *Reader) ReadEvents() ([]Event, error) {
|
||||
events, err := d.handleConInput()
|
||||
if errors.Is(err, errNotConInputReader) {
|
||||
return d.readEvents()
|
||||
}
|
||||
return events, err
|
||||
}
|
||||
|
||||
var errNotConInputReader = fmt.Errorf("handleConInput: not a conInputReader")
|
||||
|
||||
func (d *Reader) handleConInput() ([]Event, error) {
|
||||
cc, ok := d.rd.(*conInputReader)
|
||||
if !ok {
|
||||
return nil, errNotConInputReader
|
||||
}
|
||||
|
||||
var (
|
||||
events []xwindows.InputRecord
|
||||
err error
|
||||
)
|
||||
for {
|
||||
// Peek up to 256 events, this is to allow for sequences events reported as
|
||||
// key events.
|
||||
events, err = peekNConsoleInputs(cc.conin, 256)
|
||||
if cc.isCanceled() {
|
||||
return nil, cancelreader.ErrCanceled
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("peek coninput events: %w", err)
|
||||
}
|
||||
if len(events) > 0 {
|
||||
break
|
||||
}
|
||||
|
||||
// Sleep for a bit to avoid busy waiting.
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
|
||||
events, err = readNConsoleInputs(cc.conin, uint32(len(events)))
|
||||
if cc.isCanceled() {
|
||||
return nil, cancelreader.ErrCanceled
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read coninput events: %w", err)
|
||||
}
|
||||
|
||||
var evs []Event
|
||||
for _, event := range events {
|
||||
if e := d.parser.parseConInputEvent(event, &d.keyState); e != nil {
|
||||
if multi, ok := e.(MultiEvent); ok {
|
||||
evs = append(evs, multi...)
|
||||
} else {
|
||||
evs = append(evs, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return evs, nil
|
||||
}
|
||||
|
||||
func (p *Parser) parseConInputEvent(event xwindows.InputRecord, keyState *win32InputState) Event {
|
||||
switch event.EventType {
|
||||
case xwindows.KEY_EVENT:
|
||||
kevent := event.KeyEvent()
|
||||
return p.parseWin32InputKeyEvent(keyState, kevent.VirtualKeyCode, kevent.VirtualScanCode,
|
||||
kevent.Char, kevent.KeyDown, kevent.ControlKeyState, kevent.RepeatCount)
|
||||
|
||||
case xwindows.WINDOW_BUFFER_SIZE_EVENT:
|
||||
wevent := event.WindowBufferSizeEvent()
|
||||
if wevent.Size.X != keyState.lastWinsizeX || wevent.Size.Y != keyState.lastWinsizeY {
|
||||
keyState.lastWinsizeX, keyState.lastWinsizeY = wevent.Size.X, wevent.Size.Y
|
||||
return WindowSizeEvent{
|
||||
Width: int(wevent.Size.X),
|
||||
Height: int(wevent.Size.Y),
|
||||
}
|
||||
}
|
||||
case xwindows.MOUSE_EVENT:
|
||||
mevent := event.MouseEvent()
|
||||
Event := mouseEvent(keyState.lastMouseBtns, mevent)
|
||||
keyState.lastMouseBtns = mevent.ButtonState
|
||||
return Event
|
||||
case xwindows.FOCUS_EVENT:
|
||||
fevent := event.FocusEvent()
|
||||
if fevent.SetFocus {
|
||||
return FocusEvent{}
|
||||
}
|
||||
return BlurEvent{}
|
||||
case xwindows.MENU_EVENT:
|
||||
// ignore
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func mouseEventButton(p, s uint32) (MouseButton, bool) {
|
||||
var isRelease bool
|
||||
button := MouseNone
|
||||
btn := p ^ s
|
||||
if btn&s == 0 {
|
||||
isRelease = true
|
||||
}
|
||||
|
||||
if btn == 0 {
|
||||
switch {
|
||||
case s&xwindows.FROM_LEFT_1ST_BUTTON_PRESSED > 0:
|
||||
button = MouseLeft
|
||||
case s&xwindows.FROM_LEFT_2ND_BUTTON_PRESSED > 0:
|
||||
button = MouseMiddle
|
||||
case s&xwindows.RIGHTMOST_BUTTON_PRESSED > 0:
|
||||
button = MouseRight
|
||||
case s&xwindows.FROM_LEFT_3RD_BUTTON_PRESSED > 0:
|
||||
button = MouseBackward
|
||||
case s&xwindows.FROM_LEFT_4TH_BUTTON_PRESSED > 0:
|
||||
button = MouseForward
|
||||
}
|
||||
return button, isRelease
|
||||
}
|
||||
|
||||
switch btn {
|
||||
case xwindows.FROM_LEFT_1ST_BUTTON_PRESSED: // left button
|
||||
button = MouseLeft
|
||||
case xwindows.RIGHTMOST_BUTTON_PRESSED: // right button
|
||||
button = MouseRight
|
||||
case xwindows.FROM_LEFT_2ND_BUTTON_PRESSED: // middle button
|
||||
button = MouseMiddle
|
||||
case xwindows.FROM_LEFT_3RD_BUTTON_PRESSED: // unknown (possibly mouse backward)
|
||||
button = MouseBackward
|
||||
case xwindows.FROM_LEFT_4TH_BUTTON_PRESSED: // unknown (possibly mouse forward)
|
||||
button = MouseForward
|
||||
}
|
||||
|
||||
return button, isRelease
|
||||
}
|
||||
|
||||
func mouseEvent(p uint32, e xwindows.MouseEventRecord) (ev Event) {
|
||||
var mod KeyMod
|
||||
var isRelease bool
|
||||
if e.ControlKeyState&(xwindows.LEFT_ALT_PRESSED|xwindows.RIGHT_ALT_PRESSED) != 0 {
|
||||
mod |= ModAlt
|
||||
}
|
||||
if e.ControlKeyState&(xwindows.LEFT_CTRL_PRESSED|xwindows.RIGHT_CTRL_PRESSED) != 0 {
|
||||
mod |= ModCtrl
|
||||
}
|
||||
if e.ControlKeyState&(xwindows.SHIFT_PRESSED) != 0 {
|
||||
mod |= ModShift
|
||||
}
|
||||
|
||||
m := Mouse{
|
||||
X: int(e.MousePositon.X),
|
||||
Y: int(e.MousePositon.Y),
|
||||
Mod: mod,
|
||||
}
|
||||
|
||||
wheelDirection := int16(highWord(e.ButtonState)) //nolint:gosec
|
||||
switch e.EventFlags {
|
||||
case 0, xwindows.DOUBLE_CLICK:
|
||||
m.Button, isRelease = mouseEventButton(p, e.ButtonState)
|
||||
case xwindows.MOUSE_WHEELED:
|
||||
if wheelDirection > 0 {
|
||||
m.Button = MouseWheelUp
|
||||
} else {
|
||||
m.Button = MouseWheelDown
|
||||
}
|
||||
case xwindows.MOUSE_HWHEELED:
|
||||
if wheelDirection > 0 {
|
||||
m.Button = MouseWheelRight
|
||||
} else {
|
||||
m.Button = MouseWheelLeft
|
||||
}
|
||||
case xwindows.MOUSE_MOVED:
|
||||
m.Button, _ = mouseEventButton(p, e.ButtonState)
|
||||
return MouseMotionEvent(m)
|
||||
}
|
||||
|
||||
if isWheel(m.Button) {
|
||||
return MouseWheelEvent(m)
|
||||
} else if isRelease {
|
||||
return MouseReleaseEvent(m)
|
||||
}
|
||||
|
||||
return MouseClickEvent(m)
|
||||
}
|
||||
|
||||
func highWord(data uint32) uint16 {
|
||||
return uint16((data & 0xFFFF0000) >> 16) //nolint:gosec
|
||||
}
|
||||
|
||||
func readNConsoleInputs(console windows.Handle, maxEvents uint32) ([]xwindows.InputRecord, error) {
|
||||
if maxEvents == 0 {
|
||||
return nil, fmt.Errorf("maxEvents cannot be zero")
|
||||
}
|
||||
|
||||
records := make([]xwindows.InputRecord, maxEvents)
|
||||
n, err := readConsoleInput(console, records)
|
||||
return records[:n], err
|
||||
}
|
||||
|
||||
func readConsoleInput(console windows.Handle, inputRecords []xwindows.InputRecord) (uint32, error) {
|
||||
if len(inputRecords) == 0 {
|
||||
return 0, fmt.Errorf("size of input record buffer cannot be zero")
|
||||
}
|
||||
|
||||
var read uint32
|
||||
|
||||
err := xwindows.ReadConsoleInput(console, &inputRecords[0], uint32(len(inputRecords)), &read) //nolint:gosec
|
||||
|
||||
return read, err //nolint:wrapcheck
|
||||
}
|
||||
|
||||
func peekConsoleInput(console windows.Handle, inputRecords []xwindows.InputRecord) (uint32, error) {
|
||||
if len(inputRecords) == 0 {
|
||||
return 0, fmt.Errorf("size of input record buffer cannot be zero")
|
||||
}
|
||||
|
||||
var read uint32
|
||||
|
||||
err := xwindows.PeekConsoleInput(console, &inputRecords[0], uint32(len(inputRecords)), &read) //nolint:gosec
|
||||
|
||||
return read, err //nolint:wrapcheck
|
||||
}
|
||||
|
||||
func peekNConsoleInputs(console windows.Handle, maxEvents uint32) ([]xwindows.InputRecord, error) {
|
||||
if maxEvents == 0 {
|
||||
return nil, fmt.Errorf("maxEvents cannot be zero")
|
||||
}
|
||||
|
||||
records := make([]xwindows.InputRecord, maxEvents)
|
||||
n, err := peekConsoleInput(console, records)
|
||||
return records[:n], err
|
||||
}
|
||||
|
||||
// parseWin32InputKeyEvent parses a single key event from either the Windows
|
||||
// Console API or win32-input-mode events. When state is nil, it means this is
|
||||
// an event from win32-input-mode. Otherwise, it's a key event from the Windows
|
||||
// Console API and needs a state to decode ANSI escape sequences and utf16
|
||||
// runes.
|
||||
func (p *Parser) parseWin32InputKeyEvent(state *win32InputState, vkc uint16, _ uint16, r rune, keyDown bool, cks uint32, repeatCount uint16) (event Event) {
|
||||
defer func() {
|
||||
// Respect the repeat count.
|
||||
if repeatCount > 1 {
|
||||
var multi MultiEvent
|
||||
for i := 0; i < int(repeatCount); i++ {
|
||||
multi = append(multi, event)
|
||||
}
|
||||
event = multi
|
||||
}
|
||||
}()
|
||||
if state != nil {
|
||||
defer func() {
|
||||
state.lastCks = cks
|
||||
}()
|
||||
}
|
||||
|
||||
var utf8Buf [utf8.UTFMax]byte
|
||||
var key Key
|
||||
if state != nil && state.utf16Half {
|
||||
state.utf16Half = false
|
||||
state.utf16Buf[1] = r
|
||||
codepoint := utf16.DecodeRune(state.utf16Buf[0], state.utf16Buf[1])
|
||||
rw := utf8.EncodeRune(utf8Buf[:], codepoint)
|
||||
r, _ = utf8.DecodeRune(utf8Buf[:rw])
|
||||
key.Code = r
|
||||
key.Text = string(r)
|
||||
key.Mod = translateControlKeyState(cks)
|
||||
key = ensureKeyCase(key, cks)
|
||||
if keyDown {
|
||||
return KeyPressEvent(key)
|
||||
}
|
||||
return KeyReleaseEvent(key)
|
||||
}
|
||||
|
||||
var baseCode rune
|
||||
switch {
|
||||
case vkc == 0:
|
||||
// Zero means this event is either an escape code or a unicode
|
||||
// codepoint.
|
||||
if state != nil && state.ansiIdx == 0 && r != ansi.ESC {
|
||||
// This is a unicode codepoint.
|
||||
baseCode = r
|
||||
break
|
||||
}
|
||||
|
||||
if state != nil {
|
||||
// Collect ANSI escape code.
|
||||
state.ansiBuf[state.ansiIdx] = byte(r)
|
||||
state.ansiIdx++
|
||||
if state.ansiIdx <= 2 {
|
||||
// We haven't received enough bytes to determine if this is an
|
||||
// ANSI escape code.
|
||||
return nil
|
||||
}
|
||||
if r == ansi.ESC {
|
||||
// We're expecting a closing String Terminator [ansi.ST].
|
||||
return nil
|
||||
}
|
||||
|
||||
n, event := p.parseSequence(state.ansiBuf[:state.ansiIdx])
|
||||
if n == 0 {
|
||||
return nil
|
||||
}
|
||||
if _, ok := event.(UnknownEvent); ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
state.ansiIdx = 0
|
||||
return event
|
||||
}
|
||||
case vkc == xwindows.VK_BACK:
|
||||
baseCode = KeyBackspace
|
||||
case vkc == xwindows.VK_TAB:
|
||||
baseCode = KeyTab
|
||||
case vkc == xwindows.VK_RETURN:
|
||||
baseCode = KeyEnter
|
||||
case vkc == xwindows.VK_SHIFT:
|
||||
//nolint:nestif
|
||||
if cks&xwindows.SHIFT_PRESSED != 0 {
|
||||
if cks&xwindows.ENHANCED_KEY != 0 {
|
||||
baseCode = KeyRightShift
|
||||
} else {
|
||||
baseCode = KeyLeftShift
|
||||
}
|
||||
} else if state != nil {
|
||||
if state.lastCks&xwindows.SHIFT_PRESSED != 0 {
|
||||
if state.lastCks&xwindows.ENHANCED_KEY != 0 {
|
||||
baseCode = KeyRightShift
|
||||
} else {
|
||||
baseCode = KeyLeftShift
|
||||
}
|
||||
}
|
||||
}
|
||||
case vkc == xwindows.VK_CONTROL:
|
||||
if cks&xwindows.LEFT_CTRL_PRESSED != 0 {
|
||||
baseCode = KeyLeftCtrl
|
||||
} else if cks&xwindows.RIGHT_CTRL_PRESSED != 0 {
|
||||
baseCode = KeyRightCtrl
|
||||
} else if state != nil {
|
||||
if state.lastCks&xwindows.LEFT_CTRL_PRESSED != 0 {
|
||||
baseCode = KeyLeftCtrl
|
||||
} else if state.lastCks&xwindows.RIGHT_CTRL_PRESSED != 0 {
|
||||
baseCode = KeyRightCtrl
|
||||
}
|
||||
}
|
||||
case vkc == xwindows.VK_MENU:
|
||||
if cks&xwindows.LEFT_ALT_PRESSED != 0 {
|
||||
baseCode = KeyLeftAlt
|
||||
} else if cks&xwindows.RIGHT_ALT_PRESSED != 0 {
|
||||
baseCode = KeyRightAlt
|
||||
} else if state != nil {
|
||||
if state.lastCks&xwindows.LEFT_ALT_PRESSED != 0 {
|
||||
baseCode = KeyLeftAlt
|
||||
} else if state.lastCks&xwindows.RIGHT_ALT_PRESSED != 0 {
|
||||
baseCode = KeyRightAlt
|
||||
}
|
||||
}
|
||||
case vkc == xwindows.VK_PAUSE:
|
||||
baseCode = KeyPause
|
||||
case vkc == xwindows.VK_CAPITAL:
|
||||
baseCode = KeyCapsLock
|
||||
case vkc == xwindows.VK_ESCAPE:
|
||||
baseCode = KeyEscape
|
||||
case vkc == xwindows.VK_SPACE:
|
||||
baseCode = KeySpace
|
||||
case vkc == xwindows.VK_PRIOR:
|
||||
baseCode = KeyPgUp
|
||||
case vkc == xwindows.VK_NEXT:
|
||||
baseCode = KeyPgDown
|
||||
case vkc == xwindows.VK_END:
|
||||
baseCode = KeyEnd
|
||||
case vkc == xwindows.VK_HOME:
|
||||
baseCode = KeyHome
|
||||
case vkc == xwindows.VK_LEFT:
|
||||
baseCode = KeyLeft
|
||||
case vkc == xwindows.VK_UP:
|
||||
baseCode = KeyUp
|
||||
case vkc == xwindows.VK_RIGHT:
|
||||
baseCode = KeyRight
|
||||
case vkc == xwindows.VK_DOWN:
|
||||
baseCode = KeyDown
|
||||
case vkc == xwindows.VK_SELECT:
|
||||
baseCode = KeySelect
|
||||
case vkc == xwindows.VK_SNAPSHOT:
|
||||
baseCode = KeyPrintScreen
|
||||
case vkc == xwindows.VK_INSERT:
|
||||
baseCode = KeyInsert
|
||||
case vkc == xwindows.VK_DELETE:
|
||||
baseCode = KeyDelete
|
||||
case vkc >= '0' && vkc <= '9':
|
||||
baseCode = rune(vkc)
|
||||
case vkc >= 'A' && vkc <= 'Z':
|
||||
// Convert to lowercase.
|
||||
baseCode = rune(vkc) + 32
|
||||
case vkc == xwindows.VK_LWIN:
|
||||
baseCode = KeyLeftSuper
|
||||
case vkc == xwindows.VK_RWIN:
|
||||
baseCode = KeyRightSuper
|
||||
case vkc == xwindows.VK_APPS:
|
||||
baseCode = KeyMenu
|
||||
case vkc >= xwindows.VK_NUMPAD0 && vkc <= xwindows.VK_NUMPAD9:
|
||||
baseCode = rune(vkc-xwindows.VK_NUMPAD0) + KeyKp0
|
||||
case vkc == xwindows.VK_MULTIPLY:
|
||||
baseCode = KeyKpMultiply
|
||||
case vkc == xwindows.VK_ADD:
|
||||
baseCode = KeyKpPlus
|
||||
case vkc == xwindows.VK_SEPARATOR:
|
||||
baseCode = KeyKpComma
|
||||
case vkc == xwindows.VK_SUBTRACT:
|
||||
baseCode = KeyKpMinus
|
||||
case vkc == xwindows.VK_DECIMAL:
|
||||
baseCode = KeyKpDecimal
|
||||
case vkc == xwindows.VK_DIVIDE:
|
||||
baseCode = KeyKpDivide
|
||||
case vkc >= xwindows.VK_F1 && vkc <= xwindows.VK_F24:
|
||||
baseCode = rune(vkc-xwindows.VK_F1) + KeyF1
|
||||
case vkc == xwindows.VK_NUMLOCK:
|
||||
baseCode = KeyNumLock
|
||||
case vkc == xwindows.VK_SCROLL:
|
||||
baseCode = KeyScrollLock
|
||||
case vkc == xwindows.VK_LSHIFT:
|
||||
baseCode = KeyLeftShift
|
||||
case vkc == xwindows.VK_RSHIFT:
|
||||
baseCode = KeyRightShift
|
||||
case vkc == xwindows.VK_LCONTROL:
|
||||
baseCode = KeyLeftCtrl
|
||||
case vkc == xwindows.VK_RCONTROL:
|
||||
baseCode = KeyRightCtrl
|
||||
case vkc == xwindows.VK_LMENU:
|
||||
baseCode = KeyLeftAlt
|
||||
case vkc == xwindows.VK_RMENU:
|
||||
baseCode = KeyRightAlt
|
||||
case vkc == xwindows.VK_VOLUME_MUTE:
|
||||
baseCode = KeyMute
|
||||
case vkc == xwindows.VK_VOLUME_DOWN:
|
||||
baseCode = KeyLowerVol
|
||||
case vkc == xwindows.VK_VOLUME_UP:
|
||||
baseCode = KeyRaiseVol
|
||||
case vkc == xwindows.VK_MEDIA_NEXT_TRACK:
|
||||
baseCode = KeyMediaNext
|
||||
case vkc == xwindows.VK_MEDIA_PREV_TRACK:
|
||||
baseCode = KeyMediaPrev
|
||||
case vkc == xwindows.VK_MEDIA_STOP:
|
||||
baseCode = KeyMediaStop
|
||||
case vkc == xwindows.VK_MEDIA_PLAY_PAUSE:
|
||||
baseCode = KeyMediaPlayPause
|
||||
case vkc == xwindows.VK_OEM_1:
|
||||
baseCode = ';'
|
||||
case vkc == xwindows.VK_OEM_PLUS:
|
||||
baseCode = '+'
|
||||
case vkc == xwindows.VK_OEM_COMMA:
|
||||
baseCode = ','
|
||||
case vkc == xwindows.VK_OEM_MINUS:
|
||||
baseCode = '-'
|
||||
case vkc == xwindows.VK_OEM_PERIOD:
|
||||
baseCode = '.'
|
||||
case vkc == xwindows.VK_OEM_2:
|
||||
baseCode = '/'
|
||||
case vkc == xwindows.VK_OEM_3:
|
||||
baseCode = '`'
|
||||
case vkc == xwindows.VK_OEM_4:
|
||||
baseCode = '['
|
||||
case vkc == xwindows.VK_OEM_5:
|
||||
baseCode = '\\'
|
||||
case vkc == xwindows.VK_OEM_6:
|
||||
baseCode = ']'
|
||||
case vkc == xwindows.VK_OEM_7:
|
||||
baseCode = '\''
|
||||
}
|
||||
|
||||
if utf16.IsSurrogate(r) {
|
||||
if state != nil {
|
||||
state.utf16Buf[0] = r
|
||||
state.utf16Half = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AltGr is left ctrl + right alt. On non-US keyboards, this is used to type
|
||||
// special characters and produce printable events.
|
||||
// XXX: Should this be a KeyMod?
|
||||
altGr := cks&(xwindows.LEFT_CTRL_PRESSED|xwindows.RIGHT_ALT_PRESSED) == xwindows.LEFT_CTRL_PRESSED|xwindows.RIGHT_ALT_PRESSED
|
||||
|
||||
var text string
|
||||
keyCode := baseCode
|
||||
if !unicode.IsControl(r) {
|
||||
rw := utf8.EncodeRune(utf8Buf[:], r)
|
||||
keyCode, _ = utf8.DecodeRune(utf8Buf[:rw])
|
||||
if unicode.IsPrint(keyCode) && (cks == 0 ||
|
||||
cks == xwindows.SHIFT_PRESSED ||
|
||||
cks == xwindows.CAPSLOCK_ON ||
|
||||
altGr) {
|
||||
// If the control key state is 0, shift is pressed, or caps lock
|
||||
// then the key event is a printable event i.e. [text] is not empty.
|
||||
text = string(keyCode)
|
||||
}
|
||||
}
|
||||
|
||||
key.Code = keyCode
|
||||
key.Text = text
|
||||
key.Mod = translateControlKeyState(cks)
|
||||
key.BaseCode = baseCode
|
||||
key = ensureKeyCase(key, cks)
|
||||
if keyDown {
|
||||
return KeyPressEvent(key)
|
||||
}
|
||||
|
||||
return KeyReleaseEvent(key)
|
||||
}
|
||||
|
||||
// ensureKeyCase ensures that the key's text is in the correct case based on the
|
||||
// control key state.
|
||||
func ensureKeyCase(key Key, cks uint32) Key {
|
||||
if len(key.Text) == 0 {
|
||||
return key
|
||||
}
|
||||
|
||||
hasShift := cks&xwindows.SHIFT_PRESSED != 0
|
||||
hasCaps := cks&xwindows.CAPSLOCK_ON != 0
|
||||
if hasShift || hasCaps {
|
||||
if unicode.IsLower(key.Code) {
|
||||
key.ShiftedCode = unicode.ToUpper(key.Code)
|
||||
key.Text = string(key.ShiftedCode)
|
||||
}
|
||||
} else {
|
||||
if unicode.IsUpper(key.Code) {
|
||||
key.ShiftedCode = unicode.ToLower(key.Code)
|
||||
key.Text = string(key.ShiftedCode)
|
||||
}
|
||||
}
|
||||
|
||||
return key
|
||||
}
|
||||
|
||||
// translateControlKeyState translates the control key state from the Windows
|
||||
// Console API into a Mod bitmask.
|
||||
func translateControlKeyState(cks uint32) (m KeyMod) {
|
||||
if cks&xwindows.LEFT_CTRL_PRESSED != 0 || cks&xwindows.RIGHT_CTRL_PRESSED != 0 {
|
||||
m |= ModCtrl
|
||||
}
|
||||
if cks&xwindows.LEFT_ALT_PRESSED != 0 || cks&xwindows.RIGHT_ALT_PRESSED != 0 {
|
||||
m |= ModAlt
|
||||
}
|
||||
if cks&xwindows.SHIFT_PRESSED != 0 {
|
||||
m |= ModShift
|
||||
}
|
||||
if cks&xwindows.CAPSLOCK_ON != 0 {
|
||||
m |= ModCapsLock
|
||||
}
|
||||
if cks&xwindows.NUMLOCK_ON != 0 {
|
||||
m |= ModNumLock
|
||||
}
|
||||
if cks&xwindows.SCROLLLOCK_ON != 0 {
|
||||
m |= ModScrollLock
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
//nolint:unused
|
||||
func keyEventString(vkc, sc uint16, r rune, keyDown bool, cks uint32, repeatCount uint16) string {
|
||||
var s strings.Builder
|
||||
s.WriteString("vkc: ")
|
||||
s.WriteString(fmt.Sprintf("%d, 0x%02x", vkc, vkc))
|
||||
s.WriteString(", sc: ")
|
||||
s.WriteString(fmt.Sprintf("%d, 0x%02x", sc, sc))
|
||||
s.WriteString(", r: ")
|
||||
s.WriteString(fmt.Sprintf("%q", r))
|
||||
s.WriteString(", down: ")
|
||||
s.WriteString(fmt.Sprintf("%v", keyDown))
|
||||
s.WriteString(", cks: [")
|
||||
if cks&xwindows.LEFT_ALT_PRESSED != 0 {
|
||||
s.WriteString("left alt, ")
|
||||
}
|
||||
if cks&xwindows.RIGHT_ALT_PRESSED != 0 {
|
||||
s.WriteString("right alt, ")
|
||||
}
|
||||
if cks&xwindows.LEFT_CTRL_PRESSED != 0 {
|
||||
s.WriteString("left ctrl, ")
|
||||
}
|
||||
if cks&xwindows.RIGHT_CTRL_PRESSED != 0 {
|
||||
s.WriteString("right ctrl, ")
|
||||
}
|
||||
if cks&xwindows.SHIFT_PRESSED != 0 {
|
||||
s.WriteString("shift, ")
|
||||
}
|
||||
if cks&xwindows.CAPSLOCK_ON != 0 {
|
||||
s.WriteString("caps lock, ")
|
||||
}
|
||||
if cks&xwindows.NUMLOCK_ON != 0 {
|
||||
s.WriteString("num lock, ")
|
||||
}
|
||||
if cks&xwindows.SCROLLLOCK_ON != 0 {
|
||||
s.WriteString("scroll lock, ")
|
||||
}
|
||||
if cks&xwindows.ENHANCED_KEY != 0 {
|
||||
s.WriteString("enhanced key, ")
|
||||
}
|
||||
s.WriteString("], repeat count: ")
|
||||
s.WriteString(fmt.Sprintf("%d", repeatCount))
|
||||
return s.String()
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
package input
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"image/color"
|
||||
"reflect"
|
||||
"testing"
|
||||
"unicode/utf16"
|
||||
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
xwindows "github.com/charmbracelet/x/windows"
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
func TestWindowsInputEvents(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
events []xwindows.InputRecord
|
||||
expected []Event
|
||||
sequence bool // indicates that the input events are ANSI sequence or utf16
|
||||
}{
|
||||
{
|
||||
name: "single key event",
|
||||
events: []xwindows.InputRecord{
|
||||
encodeKeyEvent(xwindows.KeyEventRecord{
|
||||
KeyDown: true,
|
||||
Char: 'a',
|
||||
VirtualKeyCode: 'A',
|
||||
}),
|
||||
},
|
||||
expected: []Event{KeyPressEvent{Code: 'a', BaseCode: 'a', Text: "a"}},
|
||||
},
|
||||
{
|
||||
name: "single key event with control key",
|
||||
events: []xwindows.InputRecord{
|
||||
encodeKeyEvent(xwindows.KeyEventRecord{
|
||||
KeyDown: true,
|
||||
Char: 'a',
|
||||
VirtualKeyCode: 'A',
|
||||
ControlKeyState: xwindows.LEFT_CTRL_PRESSED,
|
||||
}),
|
||||
},
|
||||
expected: []Event{KeyPressEvent{Code: 'a', BaseCode: 'a', Mod: ModCtrl}},
|
||||
},
|
||||
{
|
||||
name: "escape alt key event",
|
||||
events: []xwindows.InputRecord{
|
||||
encodeKeyEvent(xwindows.KeyEventRecord{
|
||||
KeyDown: true,
|
||||
Char: ansi.ESC,
|
||||
VirtualKeyCode: ansi.ESC,
|
||||
ControlKeyState: xwindows.LEFT_ALT_PRESSED,
|
||||
}),
|
||||
},
|
||||
expected: []Event{KeyPressEvent{Code: ansi.ESC, BaseCode: ansi.ESC, Mod: ModAlt}},
|
||||
},
|
||||
{
|
||||
name: "single shifted key event",
|
||||
events: []xwindows.InputRecord{
|
||||
encodeKeyEvent(xwindows.KeyEventRecord{
|
||||
KeyDown: true,
|
||||
Char: 'A',
|
||||
VirtualKeyCode: 'A',
|
||||
ControlKeyState: xwindows.SHIFT_PRESSED,
|
||||
}),
|
||||
},
|
||||
expected: []Event{KeyPressEvent{Code: 'A', BaseCode: 'a', Text: "A", Mod: ModShift}},
|
||||
},
|
||||
{
|
||||
name: "utf16 rune",
|
||||
events: encodeUtf16Rune('😊'), // smiley emoji '😊'
|
||||
expected: []Event{
|
||||
KeyPressEvent{Code: '😊', Text: "😊"},
|
||||
},
|
||||
sequence: true,
|
||||
},
|
||||
{
|
||||
name: "background color response",
|
||||
events: encodeSequence("\x1b]11;rgb:ff/ff/ff\x07"),
|
||||
expected: []Event{BackgroundColorEvent{Color: color.RGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xff}}},
|
||||
sequence: true,
|
||||
},
|
||||
{
|
||||
name: "st terminated background color response",
|
||||
events: encodeSequence("\x1b]11;rgb:ffff/ffff/ffff\x1b\\"),
|
||||
expected: []Event{BackgroundColorEvent{Color: color.RGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xff}}},
|
||||
sequence: true,
|
||||
},
|
||||
{
|
||||
name: "simple mouse event",
|
||||
events: []xwindows.InputRecord{
|
||||
encodeMouseEvent(xwindows.MouseEventRecord{
|
||||
MousePositon: windows.Coord{X: 10, Y: 20},
|
||||
ButtonState: xwindows.FROM_LEFT_1ST_BUTTON_PRESSED,
|
||||
EventFlags: 0,
|
||||
}),
|
||||
encodeMouseEvent(xwindows.MouseEventRecord{
|
||||
MousePositon: windows.Coord{X: 10, Y: 20},
|
||||
EventFlags: 0,
|
||||
}),
|
||||
},
|
||||
expected: []Event{
|
||||
MouseClickEvent{Button: MouseLeft, X: 10, Y: 20},
|
||||
MouseReleaseEvent{Button: MouseLeft, X: 10, Y: 20},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "focus event",
|
||||
events: []xwindows.InputRecord{
|
||||
encodeFocusEvent(xwindows.FocusEventRecord{
|
||||
SetFocus: true,
|
||||
}),
|
||||
encodeFocusEvent(xwindows.FocusEventRecord{
|
||||
SetFocus: false,
|
||||
}),
|
||||
},
|
||||
expected: []Event{
|
||||
FocusEvent{},
|
||||
BlurEvent{},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "window size event",
|
||||
events: []xwindows.InputRecord{
|
||||
encodeWindowBufferSizeEvent(xwindows.WindowBufferSizeRecord{
|
||||
Size: windows.Coord{X: 10, Y: 20},
|
||||
}),
|
||||
},
|
||||
expected: []Event{
|
||||
WindowSizeEvent{Width: 10, Height: 20},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// p is the parser to parse the input events
|
||||
var p Parser
|
||||
|
||||
// keep track of the state of the driver to handle ANSI sequences and utf16
|
||||
var state win32InputState
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if tc.sequence {
|
||||
var Event Event
|
||||
for _, ev := range tc.events {
|
||||
if ev.EventType != xwindows.KEY_EVENT {
|
||||
t.Fatalf("expected key event, got %v", ev.EventType)
|
||||
}
|
||||
|
||||
key := ev.KeyEvent()
|
||||
Event = p.parseWin32InputKeyEvent(&state, key.VirtualKeyCode, key.VirtualScanCode, key.Char, key.KeyDown, key.ControlKeyState, key.RepeatCount)
|
||||
}
|
||||
if len(tc.expected) != 1 {
|
||||
t.Fatalf("expected 1 event, got %d", len(tc.expected))
|
||||
}
|
||||
if !reflect.DeepEqual(Event, tc.expected[0]) {
|
||||
t.Errorf("expected %v, got %v", tc.expected[0], Event)
|
||||
}
|
||||
} else {
|
||||
if len(tc.events) != len(tc.expected) {
|
||||
t.Fatalf("expected %d events, got %d", len(tc.expected), len(tc.events))
|
||||
}
|
||||
for j, ev := range tc.events {
|
||||
Event := p.parseConInputEvent(ev, &state)
|
||||
if !reflect.DeepEqual(Event, tc.expected[j]) {
|
||||
t.Errorf("expected %#v, got %#v", tc.expected[j], Event)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func boolToUint32(b bool) uint32 {
|
||||
if b {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func encodeMenuEvent(menu xwindows.MenuEventRecord) xwindows.InputRecord {
|
||||
var bts [16]byte
|
||||
binary.LittleEndian.PutUint32(bts[0:4], menu.CommandID)
|
||||
return xwindows.InputRecord{
|
||||
EventType: xwindows.MENU_EVENT,
|
||||
Event: bts,
|
||||
}
|
||||
}
|
||||
|
||||
func encodeWindowBufferSizeEvent(size xwindows.WindowBufferSizeRecord) xwindows.InputRecord {
|
||||
var bts [16]byte
|
||||
binary.LittleEndian.PutUint16(bts[0:2], uint16(size.Size.X))
|
||||
binary.LittleEndian.PutUint16(bts[2:4], uint16(size.Size.Y))
|
||||
return xwindows.InputRecord{
|
||||
EventType: xwindows.WINDOW_BUFFER_SIZE_EVENT,
|
||||
Event: bts,
|
||||
}
|
||||
}
|
||||
|
||||
func encodeFocusEvent(focus xwindows.FocusEventRecord) xwindows.InputRecord {
|
||||
var bts [16]byte
|
||||
if focus.SetFocus {
|
||||
bts[0] = 1
|
||||
}
|
||||
return xwindows.InputRecord{
|
||||
EventType: xwindows.FOCUS_EVENT,
|
||||
Event: bts,
|
||||
}
|
||||
}
|
||||
|
||||
func encodeMouseEvent(mouse xwindows.MouseEventRecord) xwindows.InputRecord {
|
||||
var bts [16]byte
|
||||
binary.LittleEndian.PutUint16(bts[0:2], uint16(mouse.MousePositon.X))
|
||||
binary.LittleEndian.PutUint16(bts[2:4], uint16(mouse.MousePositon.Y))
|
||||
binary.LittleEndian.PutUint32(bts[4:8], mouse.ButtonState)
|
||||
binary.LittleEndian.PutUint32(bts[8:12], mouse.ControlKeyState)
|
||||
binary.LittleEndian.PutUint32(bts[12:16], mouse.EventFlags)
|
||||
return xwindows.InputRecord{
|
||||
EventType: xwindows.MOUSE_EVENT,
|
||||
Event: bts,
|
||||
}
|
||||
}
|
||||
|
||||
func encodeKeyEvent(key xwindows.KeyEventRecord) xwindows.InputRecord {
|
||||
var bts [16]byte
|
||||
binary.LittleEndian.PutUint32(bts[0:4], boolToUint32(key.KeyDown))
|
||||
binary.LittleEndian.PutUint16(bts[4:6], key.RepeatCount)
|
||||
binary.LittleEndian.PutUint16(bts[6:8], key.VirtualKeyCode)
|
||||
binary.LittleEndian.PutUint16(bts[8:10], key.VirtualScanCode)
|
||||
binary.LittleEndian.PutUint16(bts[10:12], uint16(key.Char))
|
||||
binary.LittleEndian.PutUint32(bts[12:16], key.ControlKeyState)
|
||||
return xwindows.InputRecord{
|
||||
EventType: xwindows.KEY_EVENT,
|
||||
Event: bts,
|
||||
}
|
||||
}
|
||||
|
||||
// encodeSequence encodes a string of ANSI escape sequences into a slice of
|
||||
// Windows input key records.
|
||||
func encodeSequence(s string) (evs []xwindows.InputRecord) {
|
||||
var state byte
|
||||
for len(s) > 0 {
|
||||
seq, _, n, newState := ansi.DecodeSequence(s, state, nil)
|
||||
for i := 0; i < n; i++ {
|
||||
evs = append(evs, encodeKeyEvent(xwindows.KeyEventRecord{
|
||||
KeyDown: true,
|
||||
Char: rune(seq[i]),
|
||||
}))
|
||||
}
|
||||
state = newState
|
||||
s = s[n:]
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func encodeUtf16Rune(r rune) []xwindows.InputRecord {
|
||||
r1, r2 := utf16.EncodeRune(r)
|
||||
return encodeUtf16Pair(r1, r2)
|
||||
}
|
||||
|
||||
func encodeUtf16Pair(r1, r2 rune) []xwindows.InputRecord {
|
||||
return []xwindows.InputRecord{
|
||||
encodeKeyEvent(xwindows.KeyEventRecord{
|
||||
KeyDown: true,
|
||||
Char: r1,
|
||||
}),
|
||||
encodeKeyEvent(xwindows.KeyEventRecord{
|
||||
KeyDown: true,
|
||||
Char: r2,
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package input
|
||||
|
||||
// FocusEvent represents a terminal focus event.
|
||||
// This occurs when the terminal gains focus.
|
||||
type FocusEvent struct{}
|
||||
|
||||
// BlurEvent represents a terminal blur event.
|
||||
// This occurs when the terminal loses focus.
|
||||
type BlurEvent struct{}
|
||||
@@ -0,0 +1,27 @@
|
||||
package input
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFocus(t *testing.T) {
|
||||
var p Parser
|
||||
_, e := p.parseSequence([]byte("\x1b[I"))
|
||||
switch e.(type) {
|
||||
case FocusEvent:
|
||||
// ok
|
||||
default:
|
||||
t.Error("invalid sequence")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlur(t *testing.T) {
|
||||
var p Parser
|
||||
_, e := p.parseSequence([]byte("\x1b[O"))
|
||||
switch e.(type) {
|
||||
case BlurEvent:
|
||||
// ok
|
||||
default:
|
||||
t.Error("invalid sequence")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
module github.com/charmbracelet/x/input
|
||||
|
||||
go 1.23.0
|
||||
|
||||
require (
|
||||
github.com/charmbracelet/x/ansi v0.9.3
|
||||
github.com/charmbracelet/x/windows v0.2.1
|
||||
github.com/muesli/cancelreader v0.2.2
|
||||
github.com/rivo/uniseg v0.4.7
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e
|
||||
golang.org/x/sys v0.33.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect
|
||||
)
|
||||
@@ -0,0 +1,19 @@
|
||||
github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0=
|
||||
github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
|
||||
github.com/charmbracelet/x/windows v0.2.1 h1:3x7vnbpQrjpuq/4L+I4gNsG5htYoCiA5oe9hLjAij5I=
|
||||
github.com/charmbracelet/x/windows v0.2.1/go.mod h1:ptZp16h40gDYqs5TSawSVW+yiLB13j4kSMA0lSCHL0M=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
@@ -0,0 +1,45 @@
|
||||
package input
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Event represents a terminal event.
|
||||
type Event any
|
||||
|
||||
// UnknownEvent represents an unknown event.
|
||||
type UnknownEvent string
|
||||
|
||||
// String returns a string representation of the unknown event.
|
||||
func (e UnknownEvent) String() string {
|
||||
return fmt.Sprintf("%q", string(e))
|
||||
}
|
||||
|
||||
// MultiEvent represents multiple messages event.
|
||||
type MultiEvent []Event
|
||||
|
||||
// String returns a string representation of the multiple messages event.
|
||||
func (e MultiEvent) String() string {
|
||||
var sb strings.Builder
|
||||
for _, ev := range e {
|
||||
sb.WriteString(fmt.Sprintf("%v\n", ev))
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// WindowSizeEvent is used to report the terminal size. Note that Windows does
|
||||
// not have support for reporting resizes via SIGWINCH signals and relies on
|
||||
// the Windows Console API to report window size changes.
|
||||
type WindowSizeEvent struct {
|
||||
Width int
|
||||
Height int
|
||||
}
|
||||
|
||||
// WindowOpEvent is a window operation (XTWINOPS) report event. This is used to
|
||||
// report various window operations such as reporting the window size or cell
|
||||
// size.
|
||||
type WindowOpEvent struct {
|
||||
Op int
|
||||
Args []int
|
||||
}
|
||||
@@ -0,0 +1,574 @@
|
||||
package input
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
)
|
||||
|
||||
const (
|
||||
// KeyExtended is a special key code used to signify that a key event
|
||||
// contains multiple runes.
|
||||
KeyExtended = unicode.MaxRune + 1
|
||||
)
|
||||
|
||||
// Special key symbols.
|
||||
const (
|
||||
|
||||
// Special keys.
|
||||
|
||||
KeyUp rune = KeyExtended + iota + 1
|
||||
KeyDown
|
||||
KeyRight
|
||||
KeyLeft
|
||||
KeyBegin
|
||||
KeyFind
|
||||
KeyInsert
|
||||
KeyDelete
|
||||
KeySelect
|
||||
KeyPgUp
|
||||
KeyPgDown
|
||||
KeyHome
|
||||
KeyEnd
|
||||
|
||||
// Keypad keys.
|
||||
|
||||
KeyKpEnter
|
||||
KeyKpEqual
|
||||
KeyKpMultiply
|
||||
KeyKpPlus
|
||||
KeyKpComma
|
||||
KeyKpMinus
|
||||
KeyKpDecimal
|
||||
KeyKpDivide
|
||||
KeyKp0
|
||||
KeyKp1
|
||||
KeyKp2
|
||||
KeyKp3
|
||||
KeyKp4
|
||||
KeyKp5
|
||||
KeyKp6
|
||||
KeyKp7
|
||||
KeyKp8
|
||||
KeyKp9
|
||||
|
||||
//nolint:godox
|
||||
// The following are keys defined in the Kitty keyboard protocol.
|
||||
// TODO: Investigate the names of these keys.
|
||||
|
||||
KeyKpSep
|
||||
KeyKpUp
|
||||
KeyKpDown
|
||||
KeyKpLeft
|
||||
KeyKpRight
|
||||
KeyKpPgUp
|
||||
KeyKpPgDown
|
||||
KeyKpHome
|
||||
KeyKpEnd
|
||||
KeyKpInsert
|
||||
KeyKpDelete
|
||||
KeyKpBegin
|
||||
|
||||
// Function keys.
|
||||
|
||||
KeyF1
|
||||
KeyF2
|
||||
KeyF3
|
||||
KeyF4
|
||||
KeyF5
|
||||
KeyF6
|
||||
KeyF7
|
||||
KeyF8
|
||||
KeyF9
|
||||
KeyF10
|
||||
KeyF11
|
||||
KeyF12
|
||||
KeyF13
|
||||
KeyF14
|
||||
KeyF15
|
||||
KeyF16
|
||||
KeyF17
|
||||
KeyF18
|
||||
KeyF19
|
||||
KeyF20
|
||||
KeyF21
|
||||
KeyF22
|
||||
KeyF23
|
||||
KeyF24
|
||||
KeyF25
|
||||
KeyF26
|
||||
KeyF27
|
||||
KeyF28
|
||||
KeyF29
|
||||
KeyF30
|
||||
KeyF31
|
||||
KeyF32
|
||||
KeyF33
|
||||
KeyF34
|
||||
KeyF35
|
||||
KeyF36
|
||||
KeyF37
|
||||
KeyF38
|
||||
KeyF39
|
||||
KeyF40
|
||||
KeyF41
|
||||
KeyF42
|
||||
KeyF43
|
||||
KeyF44
|
||||
KeyF45
|
||||
KeyF46
|
||||
KeyF47
|
||||
KeyF48
|
||||
KeyF49
|
||||
KeyF50
|
||||
KeyF51
|
||||
KeyF52
|
||||
KeyF53
|
||||
KeyF54
|
||||
KeyF55
|
||||
KeyF56
|
||||
KeyF57
|
||||
KeyF58
|
||||
KeyF59
|
||||
KeyF60
|
||||
KeyF61
|
||||
KeyF62
|
||||
KeyF63
|
||||
|
||||
//nolint:godox
|
||||
// The following are keys defined in the Kitty keyboard protocol.
|
||||
// TODO: Investigate the names of these keys.
|
||||
|
||||
KeyCapsLock
|
||||
KeyScrollLock
|
||||
KeyNumLock
|
||||
KeyPrintScreen
|
||||
KeyPause
|
||||
KeyMenu
|
||||
|
||||
KeyMediaPlay
|
||||
KeyMediaPause
|
||||
KeyMediaPlayPause
|
||||
KeyMediaReverse
|
||||
KeyMediaStop
|
||||
KeyMediaFastForward
|
||||
KeyMediaRewind
|
||||
KeyMediaNext
|
||||
KeyMediaPrev
|
||||
KeyMediaRecord
|
||||
|
||||
KeyLowerVol
|
||||
KeyRaiseVol
|
||||
KeyMute
|
||||
|
||||
KeyLeftShift
|
||||
KeyLeftAlt
|
||||
KeyLeftCtrl
|
||||
KeyLeftSuper
|
||||
KeyLeftHyper
|
||||
KeyLeftMeta
|
||||
KeyRightShift
|
||||
KeyRightAlt
|
||||
KeyRightCtrl
|
||||
KeyRightSuper
|
||||
KeyRightHyper
|
||||
KeyRightMeta
|
||||
KeyIsoLevel3Shift
|
||||
KeyIsoLevel5Shift
|
||||
|
||||
// Special names in C0.
|
||||
|
||||
KeyBackspace = rune(ansi.DEL)
|
||||
KeyTab = rune(ansi.HT)
|
||||
KeyEnter = rune(ansi.CR)
|
||||
KeyReturn = KeyEnter
|
||||
KeyEscape = rune(ansi.ESC)
|
||||
KeyEsc = KeyEscape
|
||||
|
||||
// Special names in G0.
|
||||
|
||||
KeySpace = rune(ansi.SP)
|
||||
)
|
||||
|
||||
// KeyPressEvent represents a key press event.
|
||||
type KeyPressEvent Key
|
||||
|
||||
// String implements [fmt.Stringer] and is quite useful for matching key
|
||||
// events. For details, on what this returns see [Key.String].
|
||||
func (k KeyPressEvent) String() string {
|
||||
return Key(k).String()
|
||||
}
|
||||
|
||||
// Keystroke returns the keystroke representation of the [Key]. While less type
|
||||
// safe than looking at the individual fields, it will usually be more
|
||||
// convenient and readable to use this method when matching against keys.
|
||||
//
|
||||
// Note that modifier keys are always printed in the following order:
|
||||
// - ctrl
|
||||
// - alt
|
||||
// - shift
|
||||
// - meta
|
||||
// - hyper
|
||||
// - super
|
||||
//
|
||||
// For example, you'll always see "ctrl+shift+alt+a" and never
|
||||
// "shift+ctrl+alt+a".
|
||||
func (k KeyPressEvent) Keystroke() string {
|
||||
return Key(k).Keystroke()
|
||||
}
|
||||
|
||||
// Key returns the underlying key event. This is a syntactic sugar for casting
|
||||
// the key event to a [Key].
|
||||
func (k KeyPressEvent) Key() Key {
|
||||
return Key(k)
|
||||
}
|
||||
|
||||
// KeyReleaseEvent represents a key release event.
|
||||
type KeyReleaseEvent Key
|
||||
|
||||
// String implements [fmt.Stringer] and is quite useful for matching key
|
||||
// events. For details, on what this returns see [Key.String].
|
||||
func (k KeyReleaseEvent) String() string {
|
||||
return Key(k).String()
|
||||
}
|
||||
|
||||
// Keystroke returns the keystroke representation of the [Key]. While less type
|
||||
// safe than looking at the individual fields, it will usually be more
|
||||
// convenient and readable to use this method when matching against keys.
|
||||
//
|
||||
// Note that modifier keys are always printed in the following order:
|
||||
// - ctrl
|
||||
// - alt
|
||||
// - shift
|
||||
// - meta
|
||||
// - hyper
|
||||
// - super
|
||||
//
|
||||
// For example, you'll always see "ctrl+shift+alt+a" and never
|
||||
// "shift+ctrl+alt+a".
|
||||
func (k KeyReleaseEvent) Keystroke() string {
|
||||
return Key(k).Keystroke()
|
||||
}
|
||||
|
||||
// Key returns the underlying key event. This is a convenience method and
|
||||
// syntactic sugar to satisfy the [KeyEvent] interface, and cast the key event to
|
||||
// [Key].
|
||||
func (k KeyReleaseEvent) Key() Key {
|
||||
return Key(k)
|
||||
}
|
||||
|
||||
// KeyEvent represents a key event. This can be either a key press or a key
|
||||
// release event.
|
||||
type KeyEvent interface {
|
||||
fmt.Stringer
|
||||
|
||||
// Key returns the underlying key event.
|
||||
Key() Key
|
||||
}
|
||||
|
||||
// Key represents a Key press or release event. It contains information about
|
||||
// the Key pressed, like the runes, the type of Key, and the modifiers pressed.
|
||||
// There are a couple general patterns you could use to check for key presses
|
||||
// or releases:
|
||||
//
|
||||
// // Switch on the string representation of the key (shorter)
|
||||
// switch ev := ev.(type) {
|
||||
// case KeyPressEvent:
|
||||
// switch ev.String() {
|
||||
// case "enter":
|
||||
// fmt.Println("you pressed enter!")
|
||||
// case "a":
|
||||
// fmt.Println("you pressed a!")
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// // Switch on the key type (more foolproof)
|
||||
// switch ev := ev.(type) {
|
||||
// case KeyEvent:
|
||||
// // catch both KeyPressEvent and KeyReleaseEvent
|
||||
// switch key := ev.Key(); key.Code {
|
||||
// case KeyEnter:
|
||||
// fmt.Println("you pressed enter!")
|
||||
// default:
|
||||
// switch key.Text {
|
||||
// case "a":
|
||||
// fmt.Println("you pressed a!")
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// Note that [Key.Text] will be empty for special keys like [KeyEnter],
|
||||
// [KeyTab], and for keys that don't represent printable characters like key
|
||||
// combos with modifier keys. In other words, [Key.Text] is populated only for
|
||||
// keys that represent printable characters shifted or unshifted (like 'a',
|
||||
// 'A', '1', '!', etc.).
|
||||
type Key struct {
|
||||
// Text contains the actual characters received. This usually the same as
|
||||
// [Key.Code]. When [Key.Text] is non-empty, it indicates that the key
|
||||
// pressed represents printable character(s).
|
||||
Text string
|
||||
|
||||
// Mod represents modifier keys, like [ModCtrl], [ModAlt], and so on.
|
||||
Mod KeyMod
|
||||
|
||||
// Code represents the key pressed. This is usually a special key like
|
||||
// [KeyTab], [KeyEnter], [KeyF1], or a printable character like 'a'.
|
||||
Code rune
|
||||
|
||||
// ShiftedCode is the actual, shifted key pressed by the user. For example,
|
||||
// if the user presses shift+a, or caps lock is on, [Key.ShiftedCode] will
|
||||
// be 'A' and [Key.Code] will be 'a'.
|
||||
//
|
||||
// In the case of non-latin keyboards, like Arabic, [Key.ShiftedCode] is the
|
||||
// unshifted key on the keyboard.
|
||||
//
|
||||
// This is only available with the Kitty Keyboard Protocol or the Windows
|
||||
// Console API.
|
||||
ShiftedCode rune
|
||||
|
||||
// BaseCode is the key pressed according to the standard PC-101 key layout.
|
||||
// On international keyboards, this is the key that would be pressed if the
|
||||
// keyboard was set to US PC-101 layout.
|
||||
//
|
||||
// For example, if the user presses 'q' on a French AZERTY keyboard,
|
||||
// [Key.BaseCode] will be 'q'.
|
||||
//
|
||||
// This is only available with the Kitty Keyboard Protocol or the Windows
|
||||
// Console API.
|
||||
BaseCode rune
|
||||
|
||||
// IsRepeat indicates whether the key is being held down and sending events
|
||||
// repeatedly.
|
||||
//
|
||||
// This is only available with the Kitty Keyboard Protocol or the Windows
|
||||
// Console API.
|
||||
IsRepeat bool
|
||||
}
|
||||
|
||||
// String implements [fmt.Stringer] and is quite useful for matching key
|
||||
// events. It will return the textual representation of the [Key] if there is
|
||||
// one, otherwise, it will fallback to [Key.Keystroke].
|
||||
//
|
||||
// For example, you'll always get "?" and instead of "shift+/" on a US ANSI
|
||||
// keyboard.
|
||||
func (k Key) String() string {
|
||||
if len(k.Text) > 0 && k.Text != " " {
|
||||
return k.Text
|
||||
}
|
||||
return k.Keystroke()
|
||||
}
|
||||
|
||||
// Keystroke returns the keystroke representation of the [Key]. While less type
|
||||
// safe than looking at the individual fields, it will usually be more
|
||||
// convenient and readable to use this method when matching against keys.
|
||||
//
|
||||
// Note that modifier keys are always printed in the following order:
|
||||
// - ctrl
|
||||
// - alt
|
||||
// - shift
|
||||
// - meta
|
||||
// - hyper
|
||||
// - super
|
||||
//
|
||||
// For example, you'll always see "ctrl+shift+alt+a" and never
|
||||
// "shift+ctrl+alt+a".
|
||||
func (k Key) Keystroke() string {
|
||||
var sb strings.Builder
|
||||
if k.Mod.Contains(ModCtrl) && k.Code != KeyLeftCtrl && k.Code != KeyRightCtrl {
|
||||
sb.WriteString("ctrl+")
|
||||
}
|
||||
if k.Mod.Contains(ModAlt) && k.Code != KeyLeftAlt && k.Code != KeyRightAlt {
|
||||
sb.WriteString("alt+")
|
||||
}
|
||||
if k.Mod.Contains(ModShift) && k.Code != KeyLeftShift && k.Code != KeyRightShift {
|
||||
sb.WriteString("shift+")
|
||||
}
|
||||
if k.Mod.Contains(ModMeta) && k.Code != KeyLeftMeta && k.Code != KeyRightMeta {
|
||||
sb.WriteString("meta+")
|
||||
}
|
||||
if k.Mod.Contains(ModHyper) && k.Code != KeyLeftHyper && k.Code != KeyRightHyper {
|
||||
sb.WriteString("hyper+")
|
||||
}
|
||||
if k.Mod.Contains(ModSuper) && k.Code != KeyLeftSuper && k.Code != KeyRightSuper {
|
||||
sb.WriteString("super+")
|
||||
}
|
||||
|
||||
if kt, ok := keyTypeString[k.Code]; ok {
|
||||
sb.WriteString(kt)
|
||||
} else {
|
||||
code := k.Code
|
||||
if k.BaseCode != 0 {
|
||||
// If a [Key.BaseCode] is present, use it to represent a key using the standard
|
||||
// PC-101 key layout.
|
||||
code = k.BaseCode
|
||||
}
|
||||
|
||||
switch code {
|
||||
case KeySpace:
|
||||
// Space is the only invisible printable character.
|
||||
sb.WriteString("space")
|
||||
case KeyExtended:
|
||||
// Write the actual text of the key when the key contains multiple
|
||||
// runes.
|
||||
sb.WriteString(k.Text)
|
||||
default:
|
||||
sb.WriteRune(code)
|
||||
}
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
var keyTypeString = map[rune]string{
|
||||
KeyEnter: "enter",
|
||||
KeyTab: "tab",
|
||||
KeyBackspace: "backspace",
|
||||
KeyEscape: "esc",
|
||||
KeySpace: "space",
|
||||
KeyUp: "up",
|
||||
KeyDown: "down",
|
||||
KeyLeft: "left",
|
||||
KeyRight: "right",
|
||||
KeyBegin: "begin",
|
||||
KeyFind: "find",
|
||||
KeyInsert: "insert",
|
||||
KeyDelete: "delete",
|
||||
KeySelect: "select",
|
||||
KeyPgUp: "pgup",
|
||||
KeyPgDown: "pgdown",
|
||||
KeyHome: "home",
|
||||
KeyEnd: "end",
|
||||
KeyKpEnter: "kpenter",
|
||||
KeyKpEqual: "kpequal",
|
||||
KeyKpMultiply: "kpmul",
|
||||
KeyKpPlus: "kpplus",
|
||||
KeyKpComma: "kpcomma",
|
||||
KeyKpMinus: "kpminus",
|
||||
KeyKpDecimal: "kpperiod",
|
||||
KeyKpDivide: "kpdiv",
|
||||
KeyKp0: "kp0",
|
||||
KeyKp1: "kp1",
|
||||
KeyKp2: "kp2",
|
||||
KeyKp3: "kp3",
|
||||
KeyKp4: "kp4",
|
||||
KeyKp5: "kp5",
|
||||
KeyKp6: "kp6",
|
||||
KeyKp7: "kp7",
|
||||
KeyKp8: "kp8",
|
||||
KeyKp9: "kp9",
|
||||
|
||||
// Kitty keyboard extension
|
||||
KeyKpSep: "kpsep",
|
||||
KeyKpUp: "kpup",
|
||||
KeyKpDown: "kpdown",
|
||||
KeyKpLeft: "kpleft",
|
||||
KeyKpRight: "kpright",
|
||||
KeyKpPgUp: "kppgup",
|
||||
KeyKpPgDown: "kppgdown",
|
||||
KeyKpHome: "kphome",
|
||||
KeyKpEnd: "kpend",
|
||||
KeyKpInsert: "kpinsert",
|
||||
KeyKpDelete: "kpdelete",
|
||||
KeyKpBegin: "kpbegin",
|
||||
|
||||
KeyF1: "f1",
|
||||
KeyF2: "f2",
|
||||
KeyF3: "f3",
|
||||
KeyF4: "f4",
|
||||
KeyF5: "f5",
|
||||
KeyF6: "f6",
|
||||
KeyF7: "f7",
|
||||
KeyF8: "f8",
|
||||
KeyF9: "f9",
|
||||
KeyF10: "f10",
|
||||
KeyF11: "f11",
|
||||
KeyF12: "f12",
|
||||
KeyF13: "f13",
|
||||
KeyF14: "f14",
|
||||
KeyF15: "f15",
|
||||
KeyF16: "f16",
|
||||
KeyF17: "f17",
|
||||
KeyF18: "f18",
|
||||
KeyF19: "f19",
|
||||
KeyF20: "f20",
|
||||
KeyF21: "f21",
|
||||
KeyF22: "f22",
|
||||
KeyF23: "f23",
|
||||
KeyF24: "f24",
|
||||
KeyF25: "f25",
|
||||
KeyF26: "f26",
|
||||
KeyF27: "f27",
|
||||
KeyF28: "f28",
|
||||
KeyF29: "f29",
|
||||
KeyF30: "f30",
|
||||
KeyF31: "f31",
|
||||
KeyF32: "f32",
|
||||
KeyF33: "f33",
|
||||
KeyF34: "f34",
|
||||
KeyF35: "f35",
|
||||
KeyF36: "f36",
|
||||
KeyF37: "f37",
|
||||
KeyF38: "f38",
|
||||
KeyF39: "f39",
|
||||
KeyF40: "f40",
|
||||
KeyF41: "f41",
|
||||
KeyF42: "f42",
|
||||
KeyF43: "f43",
|
||||
KeyF44: "f44",
|
||||
KeyF45: "f45",
|
||||
KeyF46: "f46",
|
||||
KeyF47: "f47",
|
||||
KeyF48: "f48",
|
||||
KeyF49: "f49",
|
||||
KeyF50: "f50",
|
||||
KeyF51: "f51",
|
||||
KeyF52: "f52",
|
||||
KeyF53: "f53",
|
||||
KeyF54: "f54",
|
||||
KeyF55: "f55",
|
||||
KeyF56: "f56",
|
||||
KeyF57: "f57",
|
||||
KeyF58: "f58",
|
||||
KeyF59: "f59",
|
||||
KeyF60: "f60",
|
||||
KeyF61: "f61",
|
||||
KeyF62: "f62",
|
||||
KeyF63: "f63",
|
||||
|
||||
// Kitty keyboard extension
|
||||
KeyCapsLock: "capslock",
|
||||
KeyScrollLock: "scrolllock",
|
||||
KeyNumLock: "numlock",
|
||||
KeyPrintScreen: "printscreen",
|
||||
KeyPause: "pause",
|
||||
KeyMenu: "menu",
|
||||
KeyMediaPlay: "mediaplay",
|
||||
KeyMediaPause: "mediapause",
|
||||
KeyMediaPlayPause: "mediaplaypause",
|
||||
KeyMediaReverse: "mediareverse",
|
||||
KeyMediaStop: "mediastop",
|
||||
KeyMediaFastForward: "mediafastforward",
|
||||
KeyMediaRewind: "mediarewind",
|
||||
KeyMediaNext: "medianext",
|
||||
KeyMediaPrev: "mediaprev",
|
||||
KeyMediaRecord: "mediarecord",
|
||||
KeyLowerVol: "lowervol",
|
||||
KeyRaiseVol: "raisevol",
|
||||
KeyMute: "mute",
|
||||
KeyLeftShift: "leftshift",
|
||||
KeyLeftAlt: "leftalt",
|
||||
KeyLeftCtrl: "leftctrl",
|
||||
KeyLeftSuper: "leftsuper",
|
||||
KeyLeftHyper: "lefthyper",
|
||||
KeyLeftMeta: "leftmeta",
|
||||
KeyRightShift: "rightshift",
|
||||
KeyRightAlt: "rightalt",
|
||||
KeyRightCtrl: "rightctrl",
|
||||
KeyRightSuper: "rightsuper",
|
||||
KeyRightHyper: "righthyper",
|
||||
KeyRightMeta: "rightmeta",
|
||||
KeyIsoLevel3Shift: "isolevel3shift",
|
||||
KeyIsoLevel5Shift: "isolevel5shift",
|
||||
}
|
||||
@@ -0,0 +1,880 @@
|
||||
package input
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"image/color"
|
||||
"io"
|
||||
"math/rand"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
"github.com/charmbracelet/x/ansi/kitty"
|
||||
)
|
||||
|
||||
var sequences = buildKeysTable(FlagTerminfo, "dumb")
|
||||
|
||||
func TestKeyString(t *testing.T) {
|
||||
t.Run("alt+space", func(t *testing.T) {
|
||||
k := KeyPressEvent{Code: KeySpace, Mod: ModAlt}
|
||||
if got := k.String(); got != "alt+space" {
|
||||
t.Fatalf(`expected a "alt+space", got %q`, got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("runes", func(t *testing.T) {
|
||||
k := KeyPressEvent{Code: 'a', Text: "a"}
|
||||
if got := k.String(); got != "a" {
|
||||
t.Fatalf(`expected an "a", got %q`, got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid", func(t *testing.T) {
|
||||
k := KeyPressEvent{Code: 99999}
|
||||
if got := k.String(); got != "𘚟" {
|
||||
t.Fatalf(`expected a "unknown", got %q`, got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("space", func(t *testing.T) {
|
||||
k := KeyPressEvent{Code: KeySpace, Text: " "}
|
||||
if got := k.String(); got != "space" {
|
||||
t.Fatalf(`expected a "space", got %q`, got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("shift+space", func(t *testing.T) {
|
||||
k := KeyPressEvent{Code: KeySpace, Mod: ModShift}
|
||||
if got := k.String(); got != "shift+space" {
|
||||
t.Fatalf(`expected a "shift+space", got %q`, got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("?", func(t *testing.T) {
|
||||
k := KeyPressEvent{Code: '/', Mod: ModShift, Text: "?"}
|
||||
if got := k.String(); got != "?" {
|
||||
t.Fatalf(`expected a "?", got %q`, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type seqTest struct {
|
||||
seq []byte
|
||||
Events []Event
|
||||
}
|
||||
|
||||
var f3CurPosRegexp = regexp.MustCompile(`\x1b\[1;(\d+)R`)
|
||||
|
||||
// buildBaseSeqTests returns sequence tests that are valid for the
|
||||
// detectSequence() function.
|
||||
func buildBaseSeqTests() []seqTest {
|
||||
td := []seqTest{}
|
||||
for seq, key := range sequences {
|
||||
k := KeyPressEvent(key)
|
||||
st := seqTest{seq: []byte(seq), Events: []Event{k}}
|
||||
|
||||
// XXX: This is a special case to handle F3 key sequence and cursor
|
||||
// position report having the same sequence. See [parseCsi] for more
|
||||
// information.
|
||||
if f3CurPosRegexp.MatchString(seq) {
|
||||
st.Events = []Event{k, CursorPositionEvent{Y: 0, X: int(key.Mod)}}
|
||||
}
|
||||
td = append(td, st)
|
||||
}
|
||||
|
||||
// Additional special cases.
|
||||
td = append(td,
|
||||
// Unrecognized CSI sequence.
|
||||
seqTest{
|
||||
[]byte{'\x1b', '[', '-', '-', '-', '-', 'X'},
|
||||
[]Event{
|
||||
UnknownEvent([]byte{'\x1b', '[', '-', '-', '-', '-', 'X'}),
|
||||
},
|
||||
},
|
||||
// A lone space character.
|
||||
seqTest{
|
||||
[]byte{' '},
|
||||
[]Event{
|
||||
KeyPressEvent{Code: KeySpace, Text: " "},
|
||||
},
|
||||
},
|
||||
// An escape character with the alt modifier.
|
||||
seqTest{
|
||||
[]byte{'\x1b', ' '},
|
||||
[]Event{
|
||||
KeyPressEvent{Code: KeySpace, Mod: ModAlt},
|
||||
},
|
||||
},
|
||||
)
|
||||
return td
|
||||
}
|
||||
|
||||
func TestParseSequence(t *testing.T) {
|
||||
td := buildBaseSeqTests()
|
||||
td = append(td,
|
||||
// Background color.
|
||||
seqTest{
|
||||
[]byte("\x1b]11;rgb:1234/1234/1234\x07"),
|
||||
[]Event{BackgroundColorEvent{
|
||||
Color: color.RGBA{R: 0x12, G: 0x12, B: 0x12, A: 0xff},
|
||||
}},
|
||||
},
|
||||
seqTest{
|
||||
[]byte("\x1b]11;rgb:1234/1234/1234\x1b\\"),
|
||||
[]Event{BackgroundColorEvent{
|
||||
Color: color.RGBA{R: 0x12, G: 0x12, B: 0x12, A: 0xff},
|
||||
}},
|
||||
},
|
||||
seqTest{
|
||||
[]byte("\x1b]11;rgb:1234/1234/1234\x1b"), // Incomplete sequences are ignored.
|
||||
[]Event{
|
||||
UnknownEvent("\x1b]11;rgb:1234/1234/1234\x1b"),
|
||||
},
|
||||
},
|
||||
|
||||
// Kitty Graphics response.
|
||||
seqTest{
|
||||
[]byte("\x1b_Ga=t;OK\x1b\\"),
|
||||
[]Event{KittyGraphicsEvent{
|
||||
Options: kitty.Options{Action: kitty.Transmit},
|
||||
Payload: []byte("OK"),
|
||||
}},
|
||||
},
|
||||
seqTest{
|
||||
[]byte("\x1b_Gi=99,I=13;OK\x1b\\"),
|
||||
[]Event{KittyGraphicsEvent{
|
||||
Options: kitty.Options{ID: 99, Number: 13},
|
||||
Payload: []byte("OK"),
|
||||
}},
|
||||
},
|
||||
seqTest{
|
||||
[]byte("\x1b_Gi=1337,q=1;EINVAL:your face\x1b\\"),
|
||||
[]Event{KittyGraphicsEvent{
|
||||
Options: kitty.Options{ID: 1337, Quite: 1},
|
||||
Payload: []byte("EINVAL:your face"),
|
||||
}},
|
||||
},
|
||||
|
||||
// Xterm modifyOtherKeys CSI 27 ; <modifier> ; <code> ~
|
||||
seqTest{
|
||||
[]byte("\x1b[27;3;20320~"),
|
||||
[]Event{KeyPressEvent{Code: '你', Mod: ModAlt}},
|
||||
},
|
||||
seqTest{
|
||||
[]byte("\x1b[27;3;65~"),
|
||||
[]Event{KeyPressEvent{Code: 'A', Mod: ModAlt}},
|
||||
},
|
||||
seqTest{
|
||||
[]byte("\x1b[27;3;8~"),
|
||||
[]Event{KeyPressEvent{Code: KeyBackspace, Mod: ModAlt}},
|
||||
},
|
||||
seqTest{
|
||||
[]byte("\x1b[27;3;27~"),
|
||||
[]Event{KeyPressEvent{Code: KeyEscape, Mod: ModAlt}},
|
||||
},
|
||||
seqTest{
|
||||
[]byte("\x1b[27;3;127~"),
|
||||
[]Event{KeyPressEvent{Code: KeyBackspace, Mod: ModAlt}},
|
||||
},
|
||||
|
||||
// Xterm report window text area size.
|
||||
seqTest{
|
||||
[]byte("\x1b[4;24;80t"),
|
||||
[]Event{
|
||||
WindowOpEvent{Op: 4, Args: []int{24, 80}},
|
||||
},
|
||||
},
|
||||
|
||||
// Kitty keyboard / CSI u (fixterms)
|
||||
seqTest{
|
||||
[]byte("\x1b[1B"),
|
||||
[]Event{KeyPressEvent{Code: KeyDown}},
|
||||
},
|
||||
seqTest{
|
||||
[]byte("\x1b[1;B"),
|
||||
[]Event{KeyPressEvent{Code: KeyDown}},
|
||||
},
|
||||
seqTest{
|
||||
[]byte("\x1b[1;4B"),
|
||||
[]Event{KeyPressEvent{Mod: ModShift | ModAlt, Code: KeyDown}},
|
||||
},
|
||||
seqTest{
|
||||
[]byte("\x1b[1;4:1B"),
|
||||
[]Event{KeyPressEvent{Mod: ModShift | ModAlt, Code: KeyDown}},
|
||||
},
|
||||
seqTest{
|
||||
[]byte("\x1b[1;4:2B"),
|
||||
[]Event{KeyPressEvent{Mod: ModShift | ModAlt, Code: KeyDown, IsRepeat: true}},
|
||||
},
|
||||
seqTest{
|
||||
[]byte("\x1b[1;4:3B"),
|
||||
[]Event{KeyReleaseEvent{Mod: ModShift | ModAlt, Code: KeyDown}},
|
||||
},
|
||||
seqTest{
|
||||
[]byte("\x1b[8~"),
|
||||
[]Event{KeyPressEvent{Code: KeyEnd}},
|
||||
},
|
||||
seqTest{
|
||||
[]byte("\x1b[8;~"),
|
||||
[]Event{KeyPressEvent{Code: KeyEnd}},
|
||||
},
|
||||
seqTest{
|
||||
[]byte("\x1b[8;10~"),
|
||||
[]Event{KeyPressEvent{Mod: ModShift | ModMeta, Code: KeyEnd}},
|
||||
},
|
||||
seqTest{
|
||||
[]byte("\x1b[27;4u"),
|
||||
[]Event{KeyPressEvent{Mod: ModShift | ModAlt, Code: KeyEscape}},
|
||||
},
|
||||
seqTest{
|
||||
[]byte("\x1b[127;4u"),
|
||||
[]Event{KeyPressEvent{Mod: ModShift | ModAlt, Code: KeyBackspace}},
|
||||
},
|
||||
seqTest{
|
||||
[]byte("\x1b[57358;4u"),
|
||||
[]Event{KeyPressEvent{Mod: ModShift | ModAlt, Code: KeyCapsLock}},
|
||||
},
|
||||
seqTest{
|
||||
[]byte("\x1b[9;2u"),
|
||||
[]Event{KeyPressEvent{Mod: ModShift, Code: KeyTab}},
|
||||
},
|
||||
seqTest{
|
||||
[]byte("\x1b[195;u"),
|
||||
[]Event{KeyPressEvent{Text: "Ã", Code: 'Ã'}},
|
||||
},
|
||||
seqTest{
|
||||
[]byte("\x1b[20320;2u"),
|
||||
[]Event{KeyPressEvent{Text: "你", Mod: ModShift, Code: '你'}},
|
||||
},
|
||||
seqTest{
|
||||
[]byte("\x1b[195;:1u"),
|
||||
[]Event{KeyPressEvent{Text: "Ã", Code: 'Ã'}},
|
||||
},
|
||||
seqTest{
|
||||
[]byte("\x1b[195;2:3u"),
|
||||
[]Event{KeyReleaseEvent{Code: 'Ã', Text: "Ã", Mod: ModShift}},
|
||||
},
|
||||
seqTest{
|
||||
[]byte("\x1b[195;2:2u"),
|
||||
[]Event{KeyPressEvent{Code: 'Ã', Text: "Ã", IsRepeat: true, Mod: ModShift}},
|
||||
},
|
||||
seqTest{
|
||||
[]byte("\x1b[195;2:1u"),
|
||||
[]Event{KeyPressEvent{Code: 'Ã', Text: "Ã", Mod: ModShift}},
|
||||
},
|
||||
seqTest{
|
||||
[]byte("\x1b[195;2:3u"),
|
||||
[]Event{KeyReleaseEvent{Code: 'Ã', Text: "Ã", Mod: ModShift}},
|
||||
},
|
||||
seqTest{
|
||||
[]byte("\x1b[97;2;65u"),
|
||||
[]Event{KeyPressEvent{Code: 'a', Text: "A", Mod: ModShift}},
|
||||
},
|
||||
seqTest{
|
||||
[]byte("\x1b[97;;229u"),
|
||||
[]Event{KeyPressEvent{Code: 'a', Text: "å"}},
|
||||
},
|
||||
|
||||
// focus/blur
|
||||
seqTest{
|
||||
[]byte{'\x1b', '[', 'I'},
|
||||
[]Event{
|
||||
FocusEvent{},
|
||||
},
|
||||
},
|
||||
seqTest{
|
||||
[]byte{'\x1b', '[', 'O'},
|
||||
[]Event{
|
||||
BlurEvent{},
|
||||
},
|
||||
},
|
||||
// Mouse event.
|
||||
seqTest{
|
||||
[]byte{'\x1b', '[', 'M', byte(32) + 0b0100_0000, byte(65), byte(49)},
|
||||
[]Event{
|
||||
MouseWheelEvent{X: 32, Y: 16, Button: MouseWheelUp},
|
||||
},
|
||||
},
|
||||
// SGR Mouse event.
|
||||
seqTest{
|
||||
[]byte("\x1b[<0;33;17M"),
|
||||
[]Event{
|
||||
MouseClickEvent{X: 32, Y: 16, Button: MouseLeft},
|
||||
},
|
||||
},
|
||||
// Runes.
|
||||
seqTest{
|
||||
[]byte{'a'},
|
||||
[]Event{
|
||||
KeyPressEvent{Code: 'a', Text: "a"},
|
||||
},
|
||||
},
|
||||
seqTest{
|
||||
[]byte{'\x1b', 'a'},
|
||||
[]Event{
|
||||
KeyPressEvent{Code: 'a', Mod: ModAlt},
|
||||
},
|
||||
},
|
||||
seqTest{
|
||||
[]byte{'a', 'a', 'a'},
|
||||
[]Event{
|
||||
KeyPressEvent{Code: 'a', Text: "a"},
|
||||
KeyPressEvent{Code: 'a', Text: "a"},
|
||||
KeyPressEvent{Code: 'a', Text: "a"},
|
||||
},
|
||||
},
|
||||
// Multi-byte rune.
|
||||
seqTest{
|
||||
[]byte("☃"),
|
||||
[]Event{
|
||||
KeyPressEvent{Code: '☃', Text: "☃"},
|
||||
},
|
||||
},
|
||||
seqTest{
|
||||
[]byte("\x1b☃"),
|
||||
[]Event{
|
||||
KeyPressEvent{Code: '☃', Mod: ModAlt},
|
||||
},
|
||||
},
|
||||
// Standalone control characters.
|
||||
seqTest{
|
||||
[]byte{'\x1b'},
|
||||
[]Event{
|
||||
KeyPressEvent{Code: KeyEscape},
|
||||
},
|
||||
},
|
||||
seqTest{
|
||||
[]byte{ansi.SOH},
|
||||
[]Event{
|
||||
KeyPressEvent{Code: 'a', Mod: ModCtrl},
|
||||
},
|
||||
},
|
||||
seqTest{
|
||||
[]byte{'\x1b', ansi.SOH},
|
||||
[]Event{
|
||||
KeyPressEvent{Code: 'a', Mod: ModCtrl | ModAlt},
|
||||
},
|
||||
},
|
||||
seqTest{
|
||||
[]byte{ansi.NUL},
|
||||
[]Event{
|
||||
KeyPressEvent{Code: KeySpace, Mod: ModCtrl},
|
||||
},
|
||||
},
|
||||
seqTest{
|
||||
[]byte{'\x1b', ansi.NUL},
|
||||
[]Event{
|
||||
KeyPressEvent{Code: KeySpace, Mod: ModCtrl | ModAlt},
|
||||
},
|
||||
},
|
||||
// C1 control characters.
|
||||
seqTest{
|
||||
[]byte{'\x80'},
|
||||
[]Event{
|
||||
KeyPressEvent{Code: rune(0x80 - '@'), Mod: ModCtrl | ModAlt},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
if runtime.GOOS != "windows" {
|
||||
// Sadly, utf8.DecodeRune([]byte(0xfe)) returns a valid rune on windows.
|
||||
// This is incorrect, but it makes our test fail if we try it out.
|
||||
td = append(td, seqTest{
|
||||
[]byte{'\xfe'},
|
||||
[]Event{
|
||||
UnknownEvent(rune(0xfe)),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
var p Parser
|
||||
for _, tc := range td {
|
||||
t.Run(fmt.Sprintf("%q", string(tc.seq)), func(t *testing.T) {
|
||||
var events []Event
|
||||
buf := tc.seq
|
||||
for len(buf) > 0 {
|
||||
width, Event := p.parseSequence(buf)
|
||||
switch Event := Event.(type) {
|
||||
case MultiEvent:
|
||||
events = append(events, Event...)
|
||||
default:
|
||||
events = append(events, Event)
|
||||
}
|
||||
buf = buf[width:]
|
||||
}
|
||||
if !reflect.DeepEqual(tc.Events, events) {
|
||||
t.Errorf("\nexpected event for %q:\n %#v\ngot:\n %#v", tc.seq, tc.Events, events)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadLongInput(t *testing.T) {
|
||||
expect := make([]Event, 1000)
|
||||
for i := range 1000 {
|
||||
expect[i] = KeyPressEvent{Code: 'a', Text: "a"}
|
||||
}
|
||||
input := strings.Repeat("a", 1000)
|
||||
drv, err := NewReader(strings.NewReader(input), "dumb", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected input driver error: %v", err)
|
||||
}
|
||||
|
||||
var Events []Event
|
||||
for {
|
||||
events, err := drv.ReadEvents()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected input error: %v", err)
|
||||
}
|
||||
Events = append(Events, events...)
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(expect, Events) {
|
||||
t.Errorf("unexpected messages, expected:\n %+v\ngot:\n %+v", expect, Events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadInput(t *testing.T) {
|
||||
type test struct {
|
||||
keyname string
|
||||
in []byte
|
||||
out []Event
|
||||
}
|
||||
testData := []test{
|
||||
{
|
||||
"a",
|
||||
[]byte{'a'},
|
||||
[]Event{
|
||||
KeyPressEvent{Code: 'a', Text: "a"},
|
||||
},
|
||||
},
|
||||
{
|
||||
"space",
|
||||
[]byte{' '},
|
||||
[]Event{
|
||||
KeyPressEvent{Code: KeySpace, Text: " "},
|
||||
},
|
||||
},
|
||||
{
|
||||
"a alt+a",
|
||||
[]byte{'a', '\x1b', 'a'},
|
||||
[]Event{
|
||||
KeyPressEvent{Code: 'a', Text: "a"},
|
||||
KeyPressEvent{Code: 'a', Mod: ModAlt},
|
||||
},
|
||||
},
|
||||
{
|
||||
"a alt+a a",
|
||||
[]byte{'a', '\x1b', 'a', 'a'},
|
||||
[]Event{
|
||||
KeyPressEvent{Code: 'a', Text: "a"},
|
||||
KeyPressEvent{Code: 'a', Mod: ModAlt},
|
||||
KeyPressEvent{Code: 'a', Text: "a"},
|
||||
},
|
||||
},
|
||||
{
|
||||
"ctrl+a",
|
||||
[]byte{byte(ansi.SOH)},
|
||||
[]Event{
|
||||
KeyPressEvent{Code: 'a', Mod: ModCtrl},
|
||||
},
|
||||
},
|
||||
{
|
||||
"ctrl+a ctrl+b",
|
||||
[]byte{byte(ansi.SOH), byte(ansi.STX)},
|
||||
[]Event{
|
||||
KeyPressEvent{Code: 'a', Mod: ModCtrl},
|
||||
KeyPressEvent{Code: 'b', Mod: ModCtrl},
|
||||
},
|
||||
},
|
||||
{
|
||||
"alt+a",
|
||||
[]byte{byte(0x1b), 'a'},
|
||||
[]Event{
|
||||
KeyPressEvent{Code: 'a', Mod: ModAlt},
|
||||
},
|
||||
},
|
||||
{
|
||||
"a b c d",
|
||||
[]byte{'a', 'b', 'c', 'd'},
|
||||
[]Event{
|
||||
KeyPressEvent{Code: 'a', Text: "a"},
|
||||
KeyPressEvent{Code: 'b', Text: "b"},
|
||||
KeyPressEvent{Code: 'c', Text: "c"},
|
||||
KeyPressEvent{Code: 'd', Text: "d"},
|
||||
},
|
||||
},
|
||||
{
|
||||
"up",
|
||||
[]byte("\x1b[A"),
|
||||
[]Event{
|
||||
KeyPressEvent{Code: KeyUp},
|
||||
},
|
||||
},
|
||||
{
|
||||
"wheel up",
|
||||
[]byte{'\x1b', '[', 'M', byte(32) + 0b0100_0000, byte(65), byte(49)},
|
||||
[]Event{
|
||||
MouseWheelEvent{X: 32, Y: 16, Button: MouseWheelUp},
|
||||
},
|
||||
},
|
||||
{
|
||||
"left motion release",
|
||||
[]byte{
|
||||
'\x1b', '[', 'M', byte(32) + 0b0010_0000, byte(32 + 33), byte(16 + 33),
|
||||
'\x1b', '[', 'M', byte(32) + 0b0000_0011, byte(64 + 33), byte(32 + 33),
|
||||
},
|
||||
[]Event{
|
||||
MouseMotionEvent{X: 32, Y: 16, Button: MouseLeft},
|
||||
MouseReleaseEvent{X: 64, Y: 32, Button: MouseNone},
|
||||
},
|
||||
},
|
||||
{
|
||||
"shift+tab",
|
||||
[]byte{'\x1b', '[', 'Z'},
|
||||
[]Event{
|
||||
KeyPressEvent{Code: KeyTab, Mod: ModShift},
|
||||
},
|
||||
},
|
||||
{
|
||||
"enter",
|
||||
[]byte{'\r'},
|
||||
[]Event{KeyPressEvent{Code: KeyEnter}},
|
||||
},
|
||||
{
|
||||
"alt+enter",
|
||||
[]byte{'\x1b', '\r'},
|
||||
[]Event{
|
||||
KeyPressEvent{Code: KeyEnter, Mod: ModAlt},
|
||||
},
|
||||
},
|
||||
{
|
||||
"insert",
|
||||
[]byte{'\x1b', '[', '2', '~'},
|
||||
[]Event{
|
||||
KeyPressEvent{Code: KeyInsert},
|
||||
},
|
||||
},
|
||||
{
|
||||
"ctrl+alt+a",
|
||||
[]byte{'\x1b', byte(ansi.SOH)},
|
||||
[]Event{
|
||||
KeyPressEvent{Code: 'a', Mod: ModCtrl | ModAlt},
|
||||
},
|
||||
},
|
||||
{
|
||||
"CSI?----X?",
|
||||
[]byte{'\x1b', '[', '-', '-', '-', '-', 'X'},
|
||||
[]Event{UnknownEvent([]byte{'\x1b', '[', '-', '-', '-', '-', 'X'})},
|
||||
},
|
||||
// Powershell sequences.
|
||||
{
|
||||
"up",
|
||||
[]byte{'\x1b', 'O', 'A'},
|
||||
[]Event{KeyPressEvent{Code: KeyUp}},
|
||||
},
|
||||
{
|
||||
"down",
|
||||
[]byte{'\x1b', 'O', 'B'},
|
||||
[]Event{KeyPressEvent{Code: KeyDown}},
|
||||
},
|
||||
{
|
||||
"right",
|
||||
[]byte{'\x1b', 'O', 'C'},
|
||||
[]Event{KeyPressEvent{Code: KeyRight}},
|
||||
},
|
||||
{
|
||||
"left",
|
||||
[]byte{'\x1b', 'O', 'D'},
|
||||
[]Event{KeyPressEvent{Code: KeyLeft}},
|
||||
},
|
||||
{
|
||||
"alt+enter",
|
||||
[]byte{'\x1b', '\x0d'},
|
||||
[]Event{KeyPressEvent{Code: KeyEnter, Mod: ModAlt}},
|
||||
},
|
||||
{
|
||||
"alt+backspace",
|
||||
[]byte{'\x1b', '\x7f'},
|
||||
[]Event{KeyPressEvent{Code: KeyBackspace, Mod: ModAlt}},
|
||||
},
|
||||
{
|
||||
"ctrl+space",
|
||||
[]byte{'\x00'},
|
||||
[]Event{KeyPressEvent{Code: KeySpace, Mod: ModCtrl}},
|
||||
},
|
||||
{
|
||||
"ctrl+alt+space",
|
||||
[]byte{'\x1b', '\x00'},
|
||||
[]Event{KeyPressEvent{Code: KeySpace, Mod: ModCtrl | ModAlt}},
|
||||
},
|
||||
{
|
||||
"esc",
|
||||
[]byte{'\x1b'},
|
||||
[]Event{KeyPressEvent{Code: KeyEscape}},
|
||||
},
|
||||
{
|
||||
"alt+esc",
|
||||
[]byte{'\x1b', '\x1b'},
|
||||
[]Event{KeyPressEvent{Code: KeyEscape, Mod: ModAlt}},
|
||||
},
|
||||
{
|
||||
"a b o",
|
||||
[]byte{
|
||||
'\x1b', '[', '2', '0', '0', '~',
|
||||
'a', ' ', 'b',
|
||||
'\x1b', '[', '2', '0', '1', '~',
|
||||
'o',
|
||||
},
|
||||
[]Event{
|
||||
PasteStartEvent{},
|
||||
PasteEvent("a b"),
|
||||
PasteEndEvent{},
|
||||
KeyPressEvent{Code: 'o', Text: "o"},
|
||||
},
|
||||
},
|
||||
{
|
||||
"a\x03\nb",
|
||||
[]byte{
|
||||
'\x1b', '[', '2', '0', '0', '~',
|
||||
'a', '\x03', '\n', 'b',
|
||||
'\x1b', '[', '2', '0', '1', '~',
|
||||
},
|
||||
[]Event{
|
||||
PasteStartEvent{},
|
||||
PasteEvent("a\x03\nb"),
|
||||
PasteEndEvent{},
|
||||
},
|
||||
},
|
||||
{
|
||||
"?0xfe?",
|
||||
[]byte{'\xfe'},
|
||||
[]Event{
|
||||
UnknownEvent(rune(0xfe)),
|
||||
},
|
||||
},
|
||||
{
|
||||
"a ?0xfe? b",
|
||||
[]byte{'a', '\xfe', ' ', 'b'},
|
||||
[]Event{
|
||||
KeyPressEvent{Code: 'a', Text: "a"},
|
||||
UnknownEvent(rune(0xfe)),
|
||||
KeyPressEvent{Code: KeySpace, Text: " "},
|
||||
KeyPressEvent{Code: 'b', Text: "b"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for i, td := range testData {
|
||||
t.Run(fmt.Sprintf("%d: %s", i, td.keyname), func(t *testing.T) {
|
||||
Events := testReadInputs(t, bytes.NewReader(td.in))
|
||||
var buf strings.Builder
|
||||
for i, Event := range Events {
|
||||
if i > 0 {
|
||||
buf.WriteByte(' ')
|
||||
}
|
||||
if s, ok := Event.(fmt.Stringer); ok {
|
||||
buf.WriteString(s.String())
|
||||
} else {
|
||||
fmt.Fprintf(&buf, "%#v:%T", Event, Event)
|
||||
}
|
||||
}
|
||||
|
||||
if len(Events) != len(td.out) {
|
||||
t.Fatalf("unexpected message list length: got %d, expected %d\n got: %#v\n expected: %#v\n", len(Events), len(td.out), Events, td.out)
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(td.out, Events) {
|
||||
t.Fatalf("expected:\n%#v\ngot:\n%#v", td.out, Events)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func testReadInputs(t *testing.T, input io.Reader) []Event {
|
||||
// We'll check that the input reader finishes at the end
|
||||
// without error.
|
||||
var wg sync.WaitGroup
|
||||
var inputErr error
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer func() {
|
||||
cancel()
|
||||
wg.Wait()
|
||||
if inputErr != nil && !errors.Is(inputErr, io.EOF) {
|
||||
t.Fatalf("unexpected input error: %v", inputErr)
|
||||
}
|
||||
}()
|
||||
|
||||
dr, err := NewReader(input, "dumb", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected input driver error: %v", err)
|
||||
}
|
||||
|
||||
// The messages we're consuming.
|
||||
EventsC := make(chan Event)
|
||||
|
||||
// Start the reader in the background.
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
var events []Event
|
||||
events, inputErr = dr.ReadEvents()
|
||||
out:
|
||||
for _, ev := range events {
|
||||
select {
|
||||
case EventsC <- ev:
|
||||
case <-ctx.Done():
|
||||
break out
|
||||
}
|
||||
}
|
||||
EventsC <- nil
|
||||
}()
|
||||
|
||||
var Events []Event
|
||||
loop:
|
||||
for {
|
||||
select {
|
||||
case Event := <-EventsC:
|
||||
if Event == nil {
|
||||
// end of input marker for the test.
|
||||
break loop
|
||||
}
|
||||
Events = append(Events, Event)
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Errorf("timeout waiting for input event")
|
||||
break loop
|
||||
}
|
||||
}
|
||||
return Events
|
||||
}
|
||||
|
||||
// randTest defines the test input and expected output for a sequence
|
||||
// of interleaved control sequences and control characters.
|
||||
type randTest struct {
|
||||
data []byte
|
||||
lengths []int
|
||||
names []string
|
||||
}
|
||||
|
||||
// seed is the random seed to randomize the input. This helps check
|
||||
// that all the sequences get ultimately exercised.
|
||||
var seed = flag.Int64("seed", 0, "random seed (0 to autoselect)")
|
||||
|
||||
// genRandomData generates a randomized test, with a random seed unless
|
||||
// the seed flag was set.
|
||||
func genRandomData(logfn func(int64), length int) randTest {
|
||||
// We'll use a random source. However, we give the user the option
|
||||
// to override it to a specific value for reproduceability.
|
||||
s := *seed
|
||||
if s == 0 {
|
||||
s = time.Now().UnixNano()
|
||||
}
|
||||
// Inform the user so they know what to reuse to get the same data.
|
||||
logfn(s)
|
||||
return genRandomDataWithSeed(s, length)
|
||||
}
|
||||
|
||||
// genRandomDataWithSeed generates a randomized test with a fixed seed.
|
||||
func genRandomDataWithSeed(s int64, length int) randTest {
|
||||
src := rand.NewSource(s)
|
||||
r := rand.New(src)
|
||||
|
||||
// allseqs contains all the sequences, in sorted order. We sort
|
||||
// to make the test deterministic (when the seed is also fixed).
|
||||
type seqpair struct {
|
||||
seq string
|
||||
name string
|
||||
}
|
||||
var allseqs []seqpair
|
||||
for seq, key := range sequences {
|
||||
allseqs = append(allseqs, seqpair{seq, key.String()})
|
||||
}
|
||||
sort.Slice(allseqs, func(i, j int) bool { return allseqs[i].seq < allseqs[j].seq })
|
||||
|
||||
// res contains the computed test.
|
||||
var res randTest
|
||||
|
||||
for len(res.data) < length {
|
||||
alt := r.Intn(2)
|
||||
prefix := ""
|
||||
esclen := 0
|
||||
if alt == 1 {
|
||||
prefix = "alt+"
|
||||
esclen = 1
|
||||
}
|
||||
kind := r.Intn(3)
|
||||
switch kind {
|
||||
case 0:
|
||||
// A control character.
|
||||
if alt == 1 {
|
||||
res.data = append(res.data, '\x1b')
|
||||
}
|
||||
res.data = append(res.data, 1)
|
||||
res.names = append(res.names, "ctrl+"+prefix+"a")
|
||||
res.lengths = append(res.lengths, 1+esclen)
|
||||
|
||||
case 1, 2:
|
||||
// A sequence.
|
||||
seqi := r.Intn(len(allseqs))
|
||||
s := allseqs[seqi]
|
||||
if strings.Contains(s.name, "alt+") || strings.Contains(s.name, "meta+") {
|
||||
esclen = 0
|
||||
prefix = ""
|
||||
alt = 0
|
||||
}
|
||||
if alt == 1 {
|
||||
res.data = append(res.data, '\x1b')
|
||||
}
|
||||
res.data = append(res.data, s.seq...)
|
||||
if strings.HasPrefix(s.name, "ctrl+") {
|
||||
prefix = "ctrl+" + prefix
|
||||
}
|
||||
name := prefix + strings.TrimPrefix(s.name, "ctrl+")
|
||||
res.names = append(res.names, name)
|
||||
res.lengths = append(res.lengths, len(s.seq)+esclen)
|
||||
}
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func FuzzParseSequence(f *testing.F) {
|
||||
var p Parser
|
||||
for seq := range sequences {
|
||||
f.Add(seq)
|
||||
}
|
||||
f.Add("\x1b]52;?\x07") // OSC 52
|
||||
f.Add("\x1b]11;rgb:0000/0000/0000\x1b\\") // OSC 11
|
||||
f.Add("\x1bP>|charm terminal(0.1.2)\x1b\\") // DCS (XTVERSION)
|
||||
f.Add("\x1b_Gi=123\x1b\\") // APC
|
||||
f.Fuzz(func(t *testing.T, seq string) {
|
||||
n, _ := p.parseSequence([]byte(seq))
|
||||
if n == 0 && seq != "" {
|
||||
t.Errorf("expected a non-zero width for %q", seq)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// BenchmarkDetectSequenceMap benchmarks the map-based sequence
|
||||
// detector.
|
||||
func BenchmarkDetectSequenceMap(b *testing.B) {
|
||||
var p Parser
|
||||
td := genRandomDataWithSeed(123, 10000)
|
||||
for i := 0; i < b.N; i++ {
|
||||
for j, w := 0, 0; j < len(td.data); j += w {
|
||||
w, _ = p.parseSequence(td.data[j:])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
package input
|
||||
|
||||
import (
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
"github.com/charmbracelet/x/ansi/kitty"
|
||||
)
|
||||
|
||||
// KittyGraphicsEvent represents a Kitty Graphics response event.
|
||||
//
|
||||
// See https://sw.kovidgoyal.net/kitty/graphics-protocol/
|
||||
type KittyGraphicsEvent struct {
|
||||
Options kitty.Options
|
||||
Payload []byte
|
||||
}
|
||||
|
||||
// KittyEnhancementsEvent represents a Kitty enhancements event.
|
||||
type KittyEnhancementsEvent int
|
||||
|
||||
// Kitty keyboard enhancement constants.
|
||||
// See https://sw.kovidgoyal.net/kitty/keyboard-protocol/#progressive-enhancement
|
||||
const (
|
||||
KittyDisambiguateEscapeCodes KittyEnhancementsEvent = 1 << iota
|
||||
KittyReportEventTypes
|
||||
KittyReportAlternateKeys
|
||||
KittyReportAllKeysAsEscapeCodes
|
||||
KittyReportAssociatedText
|
||||
)
|
||||
|
||||
// Contains reports whether m contains the given enhancements.
|
||||
func (e KittyEnhancementsEvent) Contains(enhancements KittyEnhancementsEvent) bool {
|
||||
return e&enhancements == enhancements
|
||||
}
|
||||
|
||||
// Kitty Clipboard Control Sequences.
|
||||
var kittyKeyMap = map[int]Key{
|
||||
ansi.BS: {Code: KeyBackspace},
|
||||
ansi.HT: {Code: KeyTab},
|
||||
ansi.CR: {Code: KeyEnter},
|
||||
ansi.ESC: {Code: KeyEscape},
|
||||
ansi.DEL: {Code: KeyBackspace},
|
||||
|
||||
57344: {Code: KeyEscape},
|
||||
57345: {Code: KeyEnter},
|
||||
57346: {Code: KeyTab},
|
||||
57347: {Code: KeyBackspace},
|
||||
57348: {Code: KeyInsert},
|
||||
57349: {Code: KeyDelete},
|
||||
57350: {Code: KeyLeft},
|
||||
57351: {Code: KeyRight},
|
||||
57352: {Code: KeyUp},
|
||||
57353: {Code: KeyDown},
|
||||
57354: {Code: KeyPgUp},
|
||||
57355: {Code: KeyPgDown},
|
||||
57356: {Code: KeyHome},
|
||||
57357: {Code: KeyEnd},
|
||||
57358: {Code: KeyCapsLock},
|
||||
57359: {Code: KeyScrollLock},
|
||||
57360: {Code: KeyNumLock},
|
||||
57361: {Code: KeyPrintScreen},
|
||||
57362: {Code: KeyPause},
|
||||
57363: {Code: KeyMenu},
|
||||
57364: {Code: KeyF1},
|
||||
57365: {Code: KeyF2},
|
||||
57366: {Code: KeyF3},
|
||||
57367: {Code: KeyF4},
|
||||
57368: {Code: KeyF5},
|
||||
57369: {Code: KeyF6},
|
||||
57370: {Code: KeyF7},
|
||||
57371: {Code: KeyF8},
|
||||
57372: {Code: KeyF9},
|
||||
57373: {Code: KeyF10},
|
||||
57374: {Code: KeyF11},
|
||||
57375: {Code: KeyF12},
|
||||
57376: {Code: KeyF13},
|
||||
57377: {Code: KeyF14},
|
||||
57378: {Code: KeyF15},
|
||||
57379: {Code: KeyF16},
|
||||
57380: {Code: KeyF17},
|
||||
57381: {Code: KeyF18},
|
||||
57382: {Code: KeyF19},
|
||||
57383: {Code: KeyF20},
|
||||
57384: {Code: KeyF21},
|
||||
57385: {Code: KeyF22},
|
||||
57386: {Code: KeyF23},
|
||||
57387: {Code: KeyF24},
|
||||
57388: {Code: KeyF25},
|
||||
57389: {Code: KeyF26},
|
||||
57390: {Code: KeyF27},
|
||||
57391: {Code: KeyF28},
|
||||
57392: {Code: KeyF29},
|
||||
57393: {Code: KeyF30},
|
||||
57394: {Code: KeyF31},
|
||||
57395: {Code: KeyF32},
|
||||
57396: {Code: KeyF33},
|
||||
57397: {Code: KeyF34},
|
||||
57398: {Code: KeyF35},
|
||||
57399: {Code: KeyKp0},
|
||||
57400: {Code: KeyKp1},
|
||||
57401: {Code: KeyKp2},
|
||||
57402: {Code: KeyKp3},
|
||||
57403: {Code: KeyKp4},
|
||||
57404: {Code: KeyKp5},
|
||||
57405: {Code: KeyKp6},
|
||||
57406: {Code: KeyKp7},
|
||||
57407: {Code: KeyKp8},
|
||||
57408: {Code: KeyKp9},
|
||||
57409: {Code: KeyKpDecimal},
|
||||
57410: {Code: KeyKpDivide},
|
||||
57411: {Code: KeyKpMultiply},
|
||||
57412: {Code: KeyKpMinus},
|
||||
57413: {Code: KeyKpPlus},
|
||||
57414: {Code: KeyKpEnter},
|
||||
57415: {Code: KeyKpEqual},
|
||||
57416: {Code: KeyKpSep},
|
||||
57417: {Code: KeyKpLeft},
|
||||
57418: {Code: KeyKpRight},
|
||||
57419: {Code: KeyKpUp},
|
||||
57420: {Code: KeyKpDown},
|
||||
57421: {Code: KeyKpPgUp},
|
||||
57422: {Code: KeyKpPgDown},
|
||||
57423: {Code: KeyKpHome},
|
||||
57424: {Code: KeyKpEnd},
|
||||
57425: {Code: KeyKpInsert},
|
||||
57426: {Code: KeyKpDelete},
|
||||
57427: {Code: KeyKpBegin},
|
||||
57428: {Code: KeyMediaPlay},
|
||||
57429: {Code: KeyMediaPause},
|
||||
57430: {Code: KeyMediaPlayPause},
|
||||
57431: {Code: KeyMediaReverse},
|
||||
57432: {Code: KeyMediaStop},
|
||||
57433: {Code: KeyMediaFastForward},
|
||||
57434: {Code: KeyMediaRewind},
|
||||
57435: {Code: KeyMediaNext},
|
||||
57436: {Code: KeyMediaPrev},
|
||||
57437: {Code: KeyMediaRecord},
|
||||
57438: {Code: KeyLowerVol},
|
||||
57439: {Code: KeyRaiseVol},
|
||||
57440: {Code: KeyMute},
|
||||
57441: {Code: KeyLeftShift},
|
||||
57442: {Code: KeyLeftCtrl},
|
||||
57443: {Code: KeyLeftAlt},
|
||||
57444: {Code: KeyLeftSuper},
|
||||
57445: {Code: KeyLeftHyper},
|
||||
57446: {Code: KeyLeftMeta},
|
||||
57447: {Code: KeyRightShift},
|
||||
57448: {Code: KeyRightCtrl},
|
||||
57449: {Code: KeyRightAlt},
|
||||
57450: {Code: KeyRightSuper},
|
||||
57451: {Code: KeyRightHyper},
|
||||
57452: {Code: KeyRightMeta},
|
||||
57453: {Code: KeyIsoLevel3Shift},
|
||||
57454: {Code: KeyIsoLevel5Shift},
|
||||
}
|
||||
|
||||
func init() {
|
||||
// These are some faulty C0 mappings some terminals such as WezTerm have
|
||||
// and doesn't follow the specs.
|
||||
kittyKeyMap[ansi.NUL] = Key{Code: KeySpace, Mod: ModCtrl}
|
||||
for i := ansi.SOH; i <= ansi.SUB; i++ {
|
||||
if _, ok := kittyKeyMap[i]; !ok {
|
||||
kittyKeyMap[i] = Key{Code: rune(i + 0x60), Mod: ModCtrl}
|
||||
}
|
||||
}
|
||||
for i := ansi.FS; i <= ansi.US; i++ {
|
||||
if _, ok := kittyKeyMap[i]; !ok {
|
||||
kittyKeyMap[i] = Key{Code: rune(i + 0x40), Mod: ModCtrl}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
kittyShift = 1 << iota
|
||||
kittyAlt
|
||||
kittyCtrl
|
||||
kittySuper
|
||||
kittyHyper
|
||||
kittyMeta
|
||||
kittyCapsLock
|
||||
kittyNumLock
|
||||
)
|
||||
|
||||
func fromKittyMod(mod int) KeyMod {
|
||||
var m KeyMod
|
||||
if mod&kittyShift != 0 {
|
||||
m |= ModShift
|
||||
}
|
||||
if mod&kittyAlt != 0 {
|
||||
m |= ModAlt
|
||||
}
|
||||
if mod&kittyCtrl != 0 {
|
||||
m |= ModCtrl
|
||||
}
|
||||
if mod&kittySuper != 0 {
|
||||
m |= ModSuper
|
||||
}
|
||||
if mod&kittyHyper != 0 {
|
||||
m |= ModHyper
|
||||
}
|
||||
if mod&kittyMeta != 0 {
|
||||
m |= ModMeta
|
||||
}
|
||||
if mod&kittyCapsLock != 0 {
|
||||
m |= ModCapsLock
|
||||
}
|
||||
if mod&kittyNumLock != 0 {
|
||||
m |= ModNumLock
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// parseKittyKeyboard parses a Kitty Keyboard Protocol sequence.
|
||||
//
|
||||
// In `CSI u`, this is parsed as:
|
||||
//
|
||||
// CSI codepoint ; modifiers u
|
||||
// codepoint: ASCII Dec value
|
||||
//
|
||||
// The Kitty Keyboard Protocol extends this with optional components that can be
|
||||
// enabled progressively. The full sequence is parsed as:
|
||||
//
|
||||
// CSI unicode-key-code:alternate-key-codes ; modifiers:event-type ; text-as-codepoints u
|
||||
//
|
||||
// See https://sw.kovidgoyal.net/kitty/keyboard-protocol/
|
||||
func parseKittyKeyboard(params ansi.Params) (Event Event) {
|
||||
var isRelease bool
|
||||
var key Key
|
||||
|
||||
// The index of parameters separated by semicolons ';'. Sub parameters are
|
||||
// separated by colons ':'.
|
||||
var paramIdx int
|
||||
var sudIdx int // The sub parameter index
|
||||
for _, p := range params {
|
||||
// Kitty Keyboard Protocol has 3 optional components.
|
||||
switch paramIdx {
|
||||
case 0:
|
||||
switch sudIdx {
|
||||
case 0:
|
||||
var foundKey bool
|
||||
code := p.Param(1) // CSI u has a default value of 1
|
||||
key, foundKey = kittyKeyMap[code]
|
||||
if !foundKey {
|
||||
r := rune(code)
|
||||
if !utf8.ValidRune(r) {
|
||||
r = utf8.RuneError
|
||||
}
|
||||
|
||||
key.Code = r
|
||||
}
|
||||
|
||||
case 2:
|
||||
// shifted key + base key
|
||||
if b := rune(p.Param(1)); unicode.IsPrint(b) {
|
||||
// XXX: When alternate key reporting is enabled, the protocol
|
||||
// can return 3 things, the unicode codepoint of the key,
|
||||
// the shifted codepoint of the key, and the standard
|
||||
// PC-101 key layout codepoint.
|
||||
// This is useful to create an unambiguous mapping of keys
|
||||
// when using a different language layout.
|
||||
key.BaseCode = b
|
||||
}
|
||||
fallthrough
|
||||
|
||||
case 1:
|
||||
// shifted key
|
||||
if s := rune(p.Param(1)); unicode.IsPrint(s) {
|
||||
// XXX: We swap keys here because we want the shifted key
|
||||
// to be the Rune that is returned by the event.
|
||||
// For example, shift+a should produce "A" not "a".
|
||||
// In such a case, we set AltRune to the original key "a"
|
||||
// and Rune to "A".
|
||||
key.ShiftedCode = s
|
||||
}
|
||||
}
|
||||
case 1:
|
||||
switch sudIdx {
|
||||
case 0:
|
||||
mod := p.Param(1)
|
||||
if mod > 1 {
|
||||
key.Mod = fromKittyMod(mod - 1)
|
||||
if key.Mod > ModShift {
|
||||
// XXX: We need to clear the text if we have a modifier key
|
||||
// other than a [ModShift] key.
|
||||
key.Text = ""
|
||||
}
|
||||
}
|
||||
|
||||
case 1:
|
||||
switch p.Param(1) {
|
||||
case 2:
|
||||
key.IsRepeat = true
|
||||
case 3:
|
||||
isRelease = true
|
||||
}
|
||||
case 2:
|
||||
}
|
||||
case 2:
|
||||
if code := p.Param(0); code != 0 {
|
||||
key.Text += string(rune(code))
|
||||
}
|
||||
}
|
||||
|
||||
sudIdx++
|
||||
if !p.HasMore() {
|
||||
paramIdx++
|
||||
sudIdx = 0
|
||||
}
|
||||
}
|
||||
|
||||
//nolint:nestif
|
||||
if len(key.Text) == 0 && unicode.IsPrint(key.Code) &&
|
||||
(key.Mod <= ModShift || key.Mod == ModCapsLock || key.Mod == ModShift|ModCapsLock) {
|
||||
if key.Mod == 0 {
|
||||
key.Text = string(key.Code)
|
||||
} else {
|
||||
desiredCase := unicode.ToLower
|
||||
if key.Mod.Contains(ModShift) || key.Mod.Contains(ModCapsLock) {
|
||||
desiredCase = unicode.ToUpper
|
||||
}
|
||||
if key.ShiftedCode != 0 {
|
||||
key.Text = string(key.ShiftedCode)
|
||||
} else {
|
||||
key.Text = string(desiredCase(key.Code))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if isRelease {
|
||||
return KeyReleaseEvent(key)
|
||||
}
|
||||
|
||||
return KeyPressEvent(key)
|
||||
}
|
||||
|
||||
// parseKittyKeyboardExt parses a Kitty Keyboard Protocol sequence extensions
|
||||
// for non CSI u sequences. This includes things like CSI A, SS3 A and others,
|
||||
// and CSI ~.
|
||||
func parseKittyKeyboardExt(params ansi.Params, k KeyPressEvent) Event {
|
||||
// Handle Kitty keyboard protocol
|
||||
if len(params) > 2 && // We have at least 3 parameters
|
||||
params[0].Param(1) == 1 && // The first parameter is 1 (defaults to 1)
|
||||
params[1].HasMore() { // The second parameter is a subparameter (separated by a ":")
|
||||
switch params[2].Param(1) { // The third parameter is the event type (defaults to 1)
|
||||
case 2:
|
||||
k.IsRepeat = true
|
||||
case 3:
|
||||
return KeyReleaseEvent(k)
|
||||
}
|
||||
}
|
||||
return k
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package input
|
||||
|
||||
// KeyMod represents modifier keys.
|
||||
type KeyMod int
|
||||
|
||||
// Modifier keys.
|
||||
const (
|
||||
ModShift KeyMod = 1 << iota
|
||||
ModAlt
|
||||
ModCtrl
|
||||
ModMeta
|
||||
|
||||
// These modifiers are used with the Kitty protocol.
|
||||
// XXX: Meta and Super are swapped in the Kitty protocol,
|
||||
// this is to preserve compatibility with XTerm modifiers.
|
||||
|
||||
ModHyper
|
||||
ModSuper // Windows/Command keys
|
||||
|
||||
// These are key lock states.
|
||||
|
||||
ModCapsLock
|
||||
ModNumLock
|
||||
ModScrollLock // Defined in Windows API only
|
||||
)
|
||||
|
||||
// Contains reports whether m contains the given modifiers.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// m := ModAlt | ModCtrl
|
||||
// m.Contains(ModCtrl) // true
|
||||
// m.Contains(ModAlt | ModCtrl) // true
|
||||
// m.Contains(ModAlt | ModCtrl | ModShift) // false
|
||||
func (m KeyMod) Contains(mods KeyMod) bool {
|
||||
return m&mods == mods
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package input
|
||||
|
||||
import "github.com/charmbracelet/x/ansi"
|
||||
|
||||
// ModeReportEvent is a message that represents a mode report event (DECRPM).
|
||||
//
|
||||
// See: https://vt100.net/docs/vt510-rm/DECRPM.html
|
||||
type ModeReportEvent struct {
|
||||
// Mode is the mode number.
|
||||
Mode ansi.Mode
|
||||
|
||||
// Value is the mode value.
|
||||
Value ansi.ModeSetting
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
package input
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
)
|
||||
|
||||
// MouseButton represents the button that was pressed during a mouse message.
|
||||
type MouseButton = ansi.MouseButton
|
||||
|
||||
// Mouse event buttons
|
||||
//
|
||||
// This is based on X11 mouse button codes.
|
||||
//
|
||||
// 1 = left button
|
||||
// 2 = middle button (pressing the scroll wheel)
|
||||
// 3 = right button
|
||||
// 4 = turn scroll wheel up
|
||||
// 5 = turn scroll wheel down
|
||||
// 6 = push scroll wheel left
|
||||
// 7 = push scroll wheel right
|
||||
// 8 = 4th button (aka browser backward button)
|
||||
// 9 = 5th button (aka browser forward button)
|
||||
// 10
|
||||
// 11
|
||||
//
|
||||
// Other buttons are not supported.
|
||||
const (
|
||||
MouseNone = ansi.MouseNone
|
||||
MouseLeft = ansi.MouseLeft
|
||||
MouseMiddle = ansi.MouseMiddle
|
||||
MouseRight = ansi.MouseRight
|
||||
MouseWheelUp = ansi.MouseWheelUp
|
||||
MouseWheelDown = ansi.MouseWheelDown
|
||||
MouseWheelLeft = ansi.MouseWheelLeft
|
||||
MouseWheelRight = ansi.MouseWheelRight
|
||||
MouseBackward = ansi.MouseBackward
|
||||
MouseForward = ansi.MouseForward
|
||||
MouseButton10 = ansi.MouseButton10
|
||||
MouseButton11 = ansi.MouseButton11
|
||||
)
|
||||
|
||||
// MouseEvent represents a mouse message. This is a generic mouse message that
|
||||
// can represent any kind of mouse event.
|
||||
type MouseEvent interface {
|
||||
fmt.Stringer
|
||||
|
||||
// Mouse returns the underlying mouse event.
|
||||
Mouse() Mouse
|
||||
}
|
||||
|
||||
// Mouse represents a Mouse message. Use [MouseEvent] to represent all mouse
|
||||
// messages.
|
||||
//
|
||||
// The X and Y coordinates are zero-based, with (0,0) being the upper left
|
||||
// corner of the terminal.
|
||||
//
|
||||
// // Catch all mouse events
|
||||
// switch Event := Event.(type) {
|
||||
// case MouseEvent:
|
||||
// m := Event.Mouse()
|
||||
// fmt.Println("Mouse event:", m.X, m.Y, m)
|
||||
// }
|
||||
//
|
||||
// // Only catch mouse click events
|
||||
// switch Event := Event.(type) {
|
||||
// case MouseClickEvent:
|
||||
// fmt.Println("Mouse click event:", Event.X, Event.Y, Event)
|
||||
// }
|
||||
type Mouse struct {
|
||||
X, Y int
|
||||
Button MouseButton
|
||||
Mod KeyMod
|
||||
}
|
||||
|
||||
// String returns a string representation of the mouse message.
|
||||
func (m Mouse) String() (s string) {
|
||||
if m.Mod.Contains(ModCtrl) {
|
||||
s += "ctrl+"
|
||||
}
|
||||
if m.Mod.Contains(ModAlt) {
|
||||
s += "alt+"
|
||||
}
|
||||
if m.Mod.Contains(ModShift) {
|
||||
s += "shift+"
|
||||
}
|
||||
|
||||
str := m.Button.String()
|
||||
if str == "" {
|
||||
s += "unknown"
|
||||
} else if str != "none" { // motion events don't have a button
|
||||
s += str
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// MouseClickEvent represents a mouse button click event.
|
||||
type MouseClickEvent Mouse
|
||||
|
||||
// String returns a string representation of the mouse click event.
|
||||
func (e MouseClickEvent) String() string {
|
||||
return Mouse(e).String()
|
||||
}
|
||||
|
||||
// Mouse returns the underlying mouse event. This is a convenience method and
|
||||
// syntactic sugar to satisfy the [MouseEvent] interface, and cast the mouse
|
||||
// event to [Mouse].
|
||||
func (e MouseClickEvent) Mouse() Mouse {
|
||||
return Mouse(e)
|
||||
}
|
||||
|
||||
// MouseReleaseEvent represents a mouse button release event.
|
||||
type MouseReleaseEvent Mouse
|
||||
|
||||
// String returns a string representation of the mouse release event.
|
||||
func (e MouseReleaseEvent) String() string {
|
||||
return Mouse(e).String()
|
||||
}
|
||||
|
||||
// Mouse returns the underlying mouse event. This is a convenience method and
|
||||
// syntactic sugar to satisfy the [MouseEvent] interface, and cast the mouse
|
||||
// event to [Mouse].
|
||||
func (e MouseReleaseEvent) Mouse() Mouse {
|
||||
return Mouse(e)
|
||||
}
|
||||
|
||||
// MouseWheelEvent represents a mouse wheel message event.
|
||||
type MouseWheelEvent Mouse
|
||||
|
||||
// String returns a string representation of the mouse wheel event.
|
||||
func (e MouseWheelEvent) String() string {
|
||||
return Mouse(e).String()
|
||||
}
|
||||
|
||||
// Mouse returns the underlying mouse event. This is a convenience method and
|
||||
// syntactic sugar to satisfy the [MouseEvent] interface, and cast the mouse
|
||||
// event to [Mouse].
|
||||
func (e MouseWheelEvent) Mouse() Mouse {
|
||||
return Mouse(e)
|
||||
}
|
||||
|
||||
// MouseMotionEvent represents a mouse motion event.
|
||||
type MouseMotionEvent Mouse
|
||||
|
||||
// String returns a string representation of the mouse motion event.
|
||||
func (e MouseMotionEvent) String() string {
|
||||
m := Mouse(e)
|
||||
if m.Button != 0 {
|
||||
return m.String() + "+motion"
|
||||
}
|
||||
return m.String() + "motion"
|
||||
}
|
||||
|
||||
// Mouse returns the underlying mouse event. This is a convenience method and
|
||||
// syntactic sugar to satisfy the [MouseEvent] interface, and cast the mouse
|
||||
// event to [Mouse].
|
||||
func (e MouseMotionEvent) Mouse() Mouse {
|
||||
return Mouse(e)
|
||||
}
|
||||
|
||||
// Parse SGR-encoded mouse events; SGR extended mouse events. SGR mouse events
|
||||
// look like:
|
||||
//
|
||||
// ESC [ < Cb ; Cx ; Cy (M or m)
|
||||
//
|
||||
// where:
|
||||
//
|
||||
// Cb is the encoded button code
|
||||
// Cx is the x-coordinate of the mouse
|
||||
// Cy is the y-coordinate of the mouse
|
||||
// M is for button press, m is for button release
|
||||
//
|
||||
// https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Extended-coordinates
|
||||
func parseSGRMouseEvent(cmd ansi.Cmd, params ansi.Params) Event {
|
||||
x, _, ok := params.Param(1, 1)
|
||||
if !ok {
|
||||
x = 1
|
||||
}
|
||||
y, _, ok := params.Param(2, 1)
|
||||
if !ok {
|
||||
y = 1
|
||||
}
|
||||
release := cmd.Final() == 'm'
|
||||
b, _, _ := params.Param(0, 0)
|
||||
mod, btn, _, isMotion := parseMouseButton(b)
|
||||
|
||||
// (1,1) is the upper left. We subtract 1 to normalize it to (0,0).
|
||||
x--
|
||||
y--
|
||||
|
||||
m := Mouse{X: x, Y: y, Button: btn, Mod: mod}
|
||||
|
||||
// Wheel buttons don't have release events
|
||||
// Motion can be reported as a release event in some terminals (Windows Terminal)
|
||||
if isWheel(m.Button) {
|
||||
return MouseWheelEvent(m)
|
||||
} else if !isMotion && release {
|
||||
return MouseReleaseEvent(m)
|
||||
} else if isMotion {
|
||||
return MouseMotionEvent(m)
|
||||
}
|
||||
return MouseClickEvent(m)
|
||||
}
|
||||
|
||||
const x10MouseByteOffset = 32
|
||||
|
||||
// Parse X10-encoded mouse events; the simplest kind. The last release of X10
|
||||
// was December 1986, by the way. The original X10 mouse protocol limits the Cx
|
||||
// and Cy coordinates to 223 (=255-032).
|
||||
//
|
||||
// X10 mouse events look like:
|
||||
//
|
||||
// ESC [M Cb Cx Cy
|
||||
//
|
||||
// See: http://www.xfree86.org/current/ctlseqs.html#Mouse%20Tracking
|
||||
func parseX10MouseEvent(buf []byte) Event {
|
||||
v := buf[3:6]
|
||||
b := int(v[0])
|
||||
if b >= x10MouseByteOffset {
|
||||
// XXX: b < 32 should be impossible, but we're being defensive.
|
||||
b -= x10MouseByteOffset
|
||||
}
|
||||
|
||||
mod, btn, isRelease, isMotion := parseMouseButton(b)
|
||||
|
||||
// (1,1) is the upper left. We subtract 1 to normalize it to (0,0).
|
||||
x := int(v[1]) - x10MouseByteOffset - 1
|
||||
y := int(v[2]) - x10MouseByteOffset - 1
|
||||
|
||||
m := Mouse{X: x, Y: y, Button: btn, Mod: mod}
|
||||
if isWheel(m.Button) {
|
||||
return MouseWheelEvent(m)
|
||||
} else if isMotion {
|
||||
return MouseMotionEvent(m)
|
||||
} else if isRelease {
|
||||
return MouseReleaseEvent(m)
|
||||
}
|
||||
return MouseClickEvent(m)
|
||||
}
|
||||
|
||||
// See: https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Extended-coordinates
|
||||
func parseMouseButton(b int) (mod KeyMod, btn MouseButton, isRelease bool, isMotion bool) {
|
||||
// mouse bit shifts
|
||||
const (
|
||||
bitShift = 0b0000_0100
|
||||
bitAlt = 0b0000_1000
|
||||
bitCtrl = 0b0001_0000
|
||||
bitMotion = 0b0010_0000
|
||||
bitWheel = 0b0100_0000
|
||||
bitAdd = 0b1000_0000 // additional buttons 8-11
|
||||
|
||||
bitsMask = 0b0000_0011
|
||||
)
|
||||
|
||||
// Modifiers
|
||||
if b&bitAlt != 0 {
|
||||
mod |= ModAlt
|
||||
}
|
||||
if b&bitCtrl != 0 {
|
||||
mod |= ModCtrl
|
||||
}
|
||||
if b&bitShift != 0 {
|
||||
mod |= ModShift
|
||||
}
|
||||
|
||||
if b&bitAdd != 0 {
|
||||
btn = MouseBackward + MouseButton(b&bitsMask)
|
||||
} else if b&bitWheel != 0 {
|
||||
btn = MouseWheelUp + MouseButton(b&bitsMask)
|
||||
} else {
|
||||
btn = MouseLeft + MouseButton(b&bitsMask)
|
||||
// X10 reports a button release as 0b0000_0011 (3)
|
||||
if b&bitsMask == bitsMask {
|
||||
btn = MouseNone
|
||||
isRelease = true
|
||||
}
|
||||
}
|
||||
|
||||
// Motion bit doesn't get reported for wheel events.
|
||||
if b&bitMotion != 0 && !isWheel(btn) {
|
||||
isMotion = true
|
||||
}
|
||||
|
||||
return //nolint:nakedret
|
||||
}
|
||||
|
||||
// isWheel returns true if the mouse event is a wheel event.
|
||||
func isWheel(btn MouseButton) bool {
|
||||
return btn >= MouseWheelUp && btn <= MouseWheelRight
|
||||
}
|
||||
@@ -0,0 +1,481 @@
|
||||
package input
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
"github.com/charmbracelet/x/ansi/parser"
|
||||
)
|
||||
|
||||
func TestMouseEvent_String(t *testing.T) {
|
||||
tt := []struct {
|
||||
name string
|
||||
event Event
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "unknown",
|
||||
event: MouseClickEvent{Button: MouseButton(0xff)},
|
||||
expected: "unknown",
|
||||
},
|
||||
{
|
||||
name: "left",
|
||||
event: MouseClickEvent{Button: MouseLeft},
|
||||
expected: "left",
|
||||
},
|
||||
{
|
||||
name: "right",
|
||||
event: MouseClickEvent{Button: MouseRight},
|
||||
expected: "right",
|
||||
},
|
||||
{
|
||||
name: "middle",
|
||||
event: MouseClickEvent{Button: MouseMiddle},
|
||||
expected: "middle",
|
||||
},
|
||||
{
|
||||
name: "release",
|
||||
event: MouseReleaseEvent{Button: MouseNone},
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "wheelup",
|
||||
event: MouseWheelEvent{Button: MouseWheelUp},
|
||||
expected: "wheelup",
|
||||
},
|
||||
{
|
||||
name: "wheeldown",
|
||||
event: MouseWheelEvent{Button: MouseWheelDown},
|
||||
expected: "wheeldown",
|
||||
},
|
||||
{
|
||||
name: "wheelleft",
|
||||
event: MouseWheelEvent{Button: MouseWheelLeft},
|
||||
expected: "wheelleft",
|
||||
},
|
||||
{
|
||||
name: "wheelright",
|
||||
event: MouseWheelEvent{Button: MouseWheelRight},
|
||||
expected: "wheelright",
|
||||
},
|
||||
{
|
||||
name: "motion",
|
||||
event: MouseMotionEvent{Button: MouseNone},
|
||||
expected: "motion",
|
||||
},
|
||||
{
|
||||
name: "shift+left",
|
||||
event: MouseReleaseEvent{Button: MouseLeft, Mod: ModShift},
|
||||
expected: "shift+left",
|
||||
},
|
||||
{
|
||||
name: "shift+left", event: MouseClickEvent{Button: MouseLeft, Mod: ModShift},
|
||||
expected: "shift+left",
|
||||
},
|
||||
{
|
||||
name: "ctrl+shift+left",
|
||||
event: MouseClickEvent{Button: MouseLeft, Mod: ModCtrl | ModShift},
|
||||
expected: "ctrl+shift+left",
|
||||
},
|
||||
{
|
||||
name: "alt+left",
|
||||
event: MouseClickEvent{Button: MouseLeft, Mod: ModAlt},
|
||||
expected: "alt+left",
|
||||
},
|
||||
{
|
||||
name: "ctrl+left",
|
||||
event: MouseClickEvent{Button: MouseLeft, Mod: ModCtrl},
|
||||
expected: "ctrl+left",
|
||||
},
|
||||
{
|
||||
name: "ctrl+alt+left",
|
||||
event: MouseClickEvent{Button: MouseLeft, Mod: ModAlt | ModCtrl},
|
||||
expected: "ctrl+alt+left",
|
||||
},
|
||||
{
|
||||
name: "ctrl+alt+shift+left",
|
||||
event: MouseClickEvent{Button: MouseLeft, Mod: ModAlt | ModCtrl | ModShift},
|
||||
expected: "ctrl+alt+shift+left",
|
||||
},
|
||||
{
|
||||
name: "ignore coordinates",
|
||||
event: MouseClickEvent{X: 100, Y: 200, Button: MouseLeft},
|
||||
expected: "left",
|
||||
},
|
||||
{
|
||||
name: "broken type",
|
||||
event: MouseClickEvent{Button: MouseButton(120)},
|
||||
expected: "unknown",
|
||||
},
|
||||
}
|
||||
|
||||
for i := range tt {
|
||||
tc := tt[i]
|
||||
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
actual := fmt.Sprint(tc.event)
|
||||
|
||||
if tc.expected != actual {
|
||||
t.Fatalf("expected %q but got %q",
|
||||
tc.expected,
|
||||
actual,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseX10MouseDownEvent(t *testing.T) {
|
||||
encode := func(b byte, x, y int) []byte {
|
||||
return []byte{
|
||||
'\x1b',
|
||||
'[',
|
||||
'M',
|
||||
byte(32) + b,
|
||||
byte(x + 32 + 1),
|
||||
byte(y + 32 + 1),
|
||||
}
|
||||
}
|
||||
|
||||
tt := []struct {
|
||||
name string
|
||||
buf []byte
|
||||
expected Event
|
||||
}{
|
||||
// Position.
|
||||
{
|
||||
name: "zero position",
|
||||
buf: encode(0b0000_0000, 0, 0),
|
||||
expected: MouseClickEvent{X: 0, Y: 0, Button: MouseLeft},
|
||||
},
|
||||
{
|
||||
name: "max position",
|
||||
buf: encode(0b0000_0000, 222, 222), // Because 255 (max int8) - 32 - 1.
|
||||
expected: MouseClickEvent{X: 222, Y: 222, Button: MouseLeft},
|
||||
},
|
||||
// Simple.
|
||||
{
|
||||
name: "left",
|
||||
buf: encode(0b0000_0000, 32, 16),
|
||||
expected: MouseClickEvent{X: 32, Y: 16, Button: MouseLeft},
|
||||
},
|
||||
{
|
||||
name: "left in motion",
|
||||
buf: encode(0b0010_0000, 32, 16),
|
||||
expected: MouseMotionEvent{X: 32, Y: 16, Button: MouseLeft},
|
||||
},
|
||||
{
|
||||
name: "middle",
|
||||
buf: encode(0b0000_0001, 32, 16),
|
||||
expected: MouseClickEvent{X: 32, Y: 16, Button: MouseMiddle},
|
||||
},
|
||||
{
|
||||
name: "middle in motion",
|
||||
buf: encode(0b0010_0001, 32, 16),
|
||||
expected: MouseMotionEvent{X: 32, Y: 16, Button: MouseMiddle},
|
||||
},
|
||||
{
|
||||
name: "right",
|
||||
buf: encode(0b0000_0010, 32, 16),
|
||||
expected: MouseClickEvent{X: 32, Y: 16, Button: MouseRight},
|
||||
},
|
||||
{
|
||||
name: "right in motion",
|
||||
buf: encode(0b0010_0010, 32, 16),
|
||||
expected: MouseMotionEvent{X: 32, Y: 16, Button: MouseRight},
|
||||
},
|
||||
{
|
||||
name: "motion",
|
||||
buf: encode(0b0010_0011, 32, 16),
|
||||
expected: MouseMotionEvent{X: 32, Y: 16, Button: MouseNone},
|
||||
},
|
||||
{
|
||||
name: "wheel up",
|
||||
buf: encode(0b0100_0000, 32, 16),
|
||||
expected: MouseWheelEvent{X: 32, Y: 16, Button: MouseWheelUp},
|
||||
},
|
||||
{
|
||||
name: "wheel down",
|
||||
buf: encode(0b0100_0001, 32, 16),
|
||||
expected: MouseWheelEvent{X: 32, Y: 16, Button: MouseWheelDown},
|
||||
},
|
||||
{
|
||||
name: "wheel left",
|
||||
buf: encode(0b0100_0010, 32, 16),
|
||||
expected: MouseWheelEvent{X: 32, Y: 16, Button: MouseWheelLeft},
|
||||
},
|
||||
{
|
||||
name: "wheel right",
|
||||
buf: encode(0b0100_0011, 32, 16),
|
||||
expected: MouseWheelEvent{X: 32, Y: 16, Button: MouseWheelRight},
|
||||
},
|
||||
{
|
||||
name: "release",
|
||||
buf: encode(0b0000_0011, 32, 16),
|
||||
expected: MouseReleaseEvent{X: 32, Y: 16, Button: MouseNone},
|
||||
},
|
||||
{
|
||||
name: "backward",
|
||||
buf: encode(0b1000_0000, 32, 16),
|
||||
expected: MouseClickEvent{X: 32, Y: 16, Button: MouseBackward},
|
||||
},
|
||||
{
|
||||
name: "forward",
|
||||
buf: encode(0b1000_0001, 32, 16),
|
||||
expected: MouseClickEvent{X: 32, Y: 16, Button: MouseForward},
|
||||
},
|
||||
{
|
||||
name: "button 10",
|
||||
buf: encode(0b1000_0010, 32, 16),
|
||||
expected: MouseClickEvent{X: 32, Y: 16, Button: MouseButton10},
|
||||
},
|
||||
{
|
||||
name: "button 11",
|
||||
buf: encode(0b1000_0011, 32, 16),
|
||||
expected: MouseClickEvent{X: 32, Y: 16, Button: MouseButton11},
|
||||
},
|
||||
// Combinations.
|
||||
{
|
||||
name: "alt+right",
|
||||
buf: encode(0b0000_1010, 32, 16),
|
||||
expected: MouseClickEvent{X: 32, Y: 16, Mod: ModAlt, Button: MouseRight},
|
||||
},
|
||||
{
|
||||
name: "ctrl+right",
|
||||
buf: encode(0b0001_0010, 32, 16),
|
||||
expected: MouseClickEvent{X: 32, Y: 16, Mod: ModCtrl, Button: MouseRight},
|
||||
},
|
||||
{
|
||||
name: "left in motion",
|
||||
buf: encode(0b0010_0000, 32, 16),
|
||||
expected: MouseMotionEvent{X: 32, Y: 16, Button: MouseLeft},
|
||||
},
|
||||
{
|
||||
name: "alt+right in motion",
|
||||
buf: encode(0b0010_1010, 32, 16),
|
||||
expected: MouseMotionEvent{X: 32, Y: 16, Mod: ModAlt, Button: MouseRight},
|
||||
},
|
||||
{
|
||||
name: "ctrl+right in motion",
|
||||
buf: encode(0b0011_0010, 32, 16),
|
||||
expected: MouseMotionEvent{X: 32, Y: 16, Mod: ModCtrl, Button: MouseRight},
|
||||
},
|
||||
{
|
||||
name: "ctrl+alt+right",
|
||||
buf: encode(0b0001_1010, 32, 16),
|
||||
expected: MouseClickEvent{X: 32, Y: 16, Mod: ModAlt | ModCtrl, Button: MouseRight},
|
||||
},
|
||||
{
|
||||
name: "ctrl+wheel up",
|
||||
buf: encode(0b0101_0000, 32, 16),
|
||||
expected: MouseWheelEvent{X: 32, Y: 16, Mod: ModCtrl, Button: MouseWheelUp},
|
||||
},
|
||||
{
|
||||
name: "alt+wheel down",
|
||||
buf: encode(0b0100_1001, 32, 16),
|
||||
expected: MouseWheelEvent{X: 32, Y: 16, Mod: ModAlt, Button: MouseWheelDown},
|
||||
},
|
||||
{
|
||||
name: "ctrl+alt+wheel down",
|
||||
buf: encode(0b0101_1001, 32, 16),
|
||||
expected: MouseWheelEvent{X: 32, Y: 16, Mod: ModAlt | ModCtrl, Button: MouseWheelDown},
|
||||
},
|
||||
// Overflow position.
|
||||
{
|
||||
name: "overflow position",
|
||||
buf: encode(0b0010_0000, 250, 223), // Because 255 (max int8) - 32 - 1.
|
||||
expected: MouseMotionEvent{X: -6, Y: -33, Button: MouseLeft},
|
||||
},
|
||||
}
|
||||
|
||||
for i := range tt {
|
||||
tc := tt[i]
|
||||
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
actual := parseX10MouseEvent(tc.buf)
|
||||
|
||||
if tc.expected != actual {
|
||||
t.Fatalf("expected %#v but got %#v",
|
||||
tc.expected,
|
||||
actual,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSGRMouseEvent(t *testing.T) {
|
||||
type csiSequence struct {
|
||||
params []ansi.Param
|
||||
cmd ansi.Cmd
|
||||
}
|
||||
encode := func(b, x, y int, r bool) *csiSequence {
|
||||
re := 'M'
|
||||
if r {
|
||||
re = 'm'
|
||||
}
|
||||
return &csiSequence{
|
||||
params: []ansi.Param{
|
||||
ansi.Param(b),
|
||||
ansi.Param(x + 1),
|
||||
ansi.Param(y + 1),
|
||||
},
|
||||
cmd: ansi.Cmd(re) | ('<' << parser.PrefixShift),
|
||||
}
|
||||
}
|
||||
|
||||
tt := []struct {
|
||||
name string
|
||||
buf *csiSequence
|
||||
expected Event
|
||||
}{
|
||||
// Position.
|
||||
{
|
||||
name: "zero position",
|
||||
buf: encode(0, 0, 0, false),
|
||||
expected: MouseClickEvent{X: 0, Y: 0, Button: MouseLeft},
|
||||
},
|
||||
{
|
||||
name: "225 position",
|
||||
buf: encode(0, 225, 225, false),
|
||||
expected: MouseClickEvent{X: 225, Y: 225, Button: MouseLeft},
|
||||
},
|
||||
// Simple.
|
||||
{
|
||||
name: "left",
|
||||
buf: encode(0, 32, 16, false),
|
||||
expected: MouseClickEvent{X: 32, Y: 16, Button: MouseLeft},
|
||||
},
|
||||
{
|
||||
name: "left in motion",
|
||||
buf: encode(32, 32, 16, false),
|
||||
expected: MouseMotionEvent{X: 32, Y: 16, Button: MouseLeft},
|
||||
},
|
||||
{
|
||||
name: "left",
|
||||
buf: encode(0, 32, 16, true),
|
||||
expected: MouseReleaseEvent{X: 32, Y: 16, Button: MouseLeft},
|
||||
},
|
||||
{
|
||||
name: "middle",
|
||||
buf: encode(1, 32, 16, false),
|
||||
expected: MouseClickEvent{X: 32, Y: 16, Button: MouseMiddle},
|
||||
},
|
||||
{
|
||||
name: "middle in motion",
|
||||
buf: encode(33, 32, 16, false),
|
||||
expected: MouseMotionEvent{X: 32, Y: 16, Button: MouseMiddle},
|
||||
},
|
||||
{
|
||||
name: "middle",
|
||||
buf: encode(1, 32, 16, true),
|
||||
expected: MouseReleaseEvent{X: 32, Y: 16, Button: MouseMiddle},
|
||||
},
|
||||
{
|
||||
name: "right",
|
||||
buf: encode(2, 32, 16, false),
|
||||
expected: MouseClickEvent{X: 32, Y: 16, Button: MouseRight},
|
||||
},
|
||||
{
|
||||
name: "right",
|
||||
buf: encode(2, 32, 16, true),
|
||||
expected: MouseReleaseEvent{X: 32, Y: 16, Button: MouseRight},
|
||||
},
|
||||
{
|
||||
name: "motion",
|
||||
buf: encode(35, 32, 16, false),
|
||||
expected: MouseMotionEvent{X: 32, Y: 16, Button: MouseNone},
|
||||
},
|
||||
{
|
||||
name: "wheel up",
|
||||
buf: encode(64, 32, 16, false),
|
||||
expected: MouseWheelEvent{X: 32, Y: 16, Button: MouseWheelUp},
|
||||
},
|
||||
{
|
||||
name: "wheel down",
|
||||
buf: encode(65, 32, 16, false),
|
||||
expected: MouseWheelEvent{X: 32, Y: 16, Button: MouseWheelDown},
|
||||
},
|
||||
{
|
||||
name: "wheel left",
|
||||
buf: encode(66, 32, 16, false),
|
||||
expected: MouseWheelEvent{X: 32, Y: 16, Button: MouseWheelLeft},
|
||||
},
|
||||
{
|
||||
name: "wheel right",
|
||||
buf: encode(67, 32, 16, false),
|
||||
expected: MouseWheelEvent{X: 32, Y: 16, Button: MouseWheelRight},
|
||||
},
|
||||
{
|
||||
name: "backward",
|
||||
buf: encode(128, 32, 16, false),
|
||||
expected: MouseClickEvent{X: 32, Y: 16, Button: MouseBackward},
|
||||
},
|
||||
{
|
||||
name: "backward in motion",
|
||||
buf: encode(160, 32, 16, false),
|
||||
expected: MouseMotionEvent{X: 32, Y: 16, Button: MouseBackward},
|
||||
},
|
||||
{
|
||||
name: "forward",
|
||||
buf: encode(129, 32, 16, false),
|
||||
expected: MouseClickEvent{X: 32, Y: 16, Button: MouseForward},
|
||||
},
|
||||
{
|
||||
name: "forward in motion",
|
||||
buf: encode(161, 32, 16, false),
|
||||
expected: MouseMotionEvent{X: 32, Y: 16, Button: MouseForward},
|
||||
},
|
||||
// Combinations.
|
||||
{
|
||||
name: "alt+right",
|
||||
buf: encode(10, 32, 16, false),
|
||||
expected: MouseClickEvent{X: 32, Y: 16, Mod: ModAlt, Button: MouseRight},
|
||||
},
|
||||
{
|
||||
name: "ctrl+right",
|
||||
buf: encode(18, 32, 16, false),
|
||||
expected: MouseClickEvent{X: 32, Y: 16, Mod: ModCtrl, Button: MouseRight},
|
||||
},
|
||||
{
|
||||
name: "ctrl+alt+right",
|
||||
buf: encode(26, 32, 16, false),
|
||||
expected: MouseClickEvent{X: 32, Y: 16, Mod: ModAlt | ModCtrl, Button: MouseRight},
|
||||
},
|
||||
{
|
||||
name: "alt+wheel",
|
||||
buf: encode(73, 32, 16, false),
|
||||
expected: MouseWheelEvent{X: 32, Y: 16, Mod: ModAlt, Button: MouseWheelDown},
|
||||
},
|
||||
{
|
||||
name: "ctrl+wheel",
|
||||
buf: encode(81, 32, 16, false),
|
||||
expected: MouseWheelEvent{X: 32, Y: 16, Mod: ModCtrl, Button: MouseWheelDown},
|
||||
},
|
||||
{
|
||||
name: "ctrl+alt+wheel",
|
||||
buf: encode(89, 32, 16, false),
|
||||
expected: MouseWheelEvent{X: 32, Y: 16, Mod: ModAlt | ModCtrl, Button: MouseWheelDown},
|
||||
},
|
||||
{
|
||||
name: "ctrl+alt+shift+wheel",
|
||||
buf: encode(93, 32, 16, false),
|
||||
expected: MouseWheelEvent{X: 32, Y: 16, Mod: ModAlt | ModShift | ModCtrl, Button: MouseWheelDown},
|
||||
},
|
||||
}
|
||||
|
||||
for i := range tt {
|
||||
tc := tt[i]
|
||||
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
actual := parseSGRMouseEvent(tc.buf.cmd, tc.buf.params)
|
||||
if tc.expected != actual {
|
||||
t.Fatalf("expected %#v but got %#v",
|
||||
tc.expected,
|
||||
actual,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,47 @@
|
||||
package input
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
)
|
||||
|
||||
func TestParseSequence_Events(t *testing.T) {
|
||||
input := []byte("\x1b\x1b[Ztest\x00\x1b]10;rgb:1234/1234/1234\x07\x1b[27;2;27~\x1b[?1049;2$y\x1b[4;1$y")
|
||||
want := []Event{
|
||||
KeyPressEvent{Code: KeyTab, Mod: ModShift | ModAlt},
|
||||
KeyPressEvent{Code: 't', Text: "t"},
|
||||
KeyPressEvent{Code: 'e', Text: "e"},
|
||||
KeyPressEvent{Code: 's', Text: "s"},
|
||||
KeyPressEvent{Code: 't', Text: "t"},
|
||||
KeyPressEvent{Code: KeySpace, Mod: ModCtrl},
|
||||
ForegroundColorEvent{color.RGBA{R: 0x12, G: 0x12, B: 0x12, A: 0xff}},
|
||||
KeyPressEvent{Code: KeyEscape, Mod: ModShift},
|
||||
ModeReportEvent{Mode: ansi.AltScreenSaveCursorMode, Value: ansi.ModeReset},
|
||||
ModeReportEvent{Mode: ansi.InsertReplaceMode, Value: ansi.ModeSet},
|
||||
}
|
||||
|
||||
var p Parser
|
||||
for i := 0; len(input) != 0; i++ {
|
||||
if i >= len(want) {
|
||||
t.Fatalf("reached end of want events")
|
||||
}
|
||||
n, got := p.parseSequence(input)
|
||||
if !reflect.DeepEqual(got, want[i]) {
|
||||
t.Errorf("got %#v (%T), want %#v (%T)", got, got, want[i], want[i])
|
||||
}
|
||||
input = input[n:]
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkParseSequence(b *testing.B) {
|
||||
var p Parser
|
||||
input := []byte("\x1b\x1b[Ztest\x00\x1b]10;1234/1234/1234\x07\x1b[27;2;27~")
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
p.parseSequence(input)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package input
|
||||
|
||||
// PasteEvent is an message that is emitted when a terminal receives pasted text
|
||||
// using bracketed-paste.
|
||||
type PasteEvent string
|
||||
|
||||
// PasteStartEvent is an message that is emitted when the terminal starts the
|
||||
// bracketed-paste text.
|
||||
type PasteStartEvent struct{}
|
||||
|
||||
// PasteEndEvent is an message that is emitted when the terminal ends the
|
||||
// bracketed-paste text.
|
||||
type PasteEndEvent struct{}
|
||||
@@ -0,0 +1,389 @@
|
||||
package input
|
||||
|
||||
import (
|
||||
"maps"
|
||||
"strconv"
|
||||
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
)
|
||||
|
||||
// buildKeysTable builds a table of key sequences and their corresponding key
|
||||
// events based on the VT100/VT200, XTerm, and Urxvt terminal specs.
|
||||
func buildKeysTable(flags int, term string) map[string]Key {
|
||||
nul := Key{Code: KeySpace, Mod: ModCtrl} // ctrl+@ or ctrl+space
|
||||
if flags&FlagCtrlAt != 0 {
|
||||
nul = Key{Code: '@', Mod: ModCtrl}
|
||||
}
|
||||
|
||||
tab := Key{Code: KeyTab} // ctrl+i or tab
|
||||
if flags&FlagCtrlI != 0 {
|
||||
tab = Key{Code: 'i', Mod: ModCtrl}
|
||||
}
|
||||
|
||||
enter := Key{Code: KeyEnter} // ctrl+m or enter
|
||||
if flags&FlagCtrlM != 0 {
|
||||
enter = Key{Code: 'm', Mod: ModCtrl}
|
||||
}
|
||||
|
||||
esc := Key{Code: KeyEscape} // ctrl+[ or escape
|
||||
if flags&FlagCtrlOpenBracket != 0 {
|
||||
esc = Key{Code: '[', Mod: ModCtrl} // ctrl+[ or escape
|
||||
}
|
||||
|
||||
del := Key{Code: KeyBackspace}
|
||||
if flags&FlagBackspace != 0 {
|
||||
del.Code = KeyDelete
|
||||
}
|
||||
|
||||
find := Key{Code: KeyHome}
|
||||
if flags&FlagFind != 0 {
|
||||
find.Code = KeyFind
|
||||
}
|
||||
|
||||
sel := Key{Code: KeyEnd}
|
||||
if flags&FlagSelect != 0 {
|
||||
sel.Code = KeySelect
|
||||
}
|
||||
|
||||
// The following is a table of key sequences and their corresponding key
|
||||
// events based on the VT100/VT200 terminal specs.
|
||||
//
|
||||
// See: https://vt100.net/docs/vt100-ug/chapter3.html#S3.2
|
||||
// See: https://vt100.net/docs/vt220-rm/chapter3.html
|
||||
//
|
||||
// XXX: These keys may be overwritten by other options like XTerm or
|
||||
// Terminfo.
|
||||
table := map[string]Key{
|
||||
// C0 control characters
|
||||
string(byte(ansi.NUL)): nul,
|
||||
string(byte(ansi.SOH)): {Code: 'a', Mod: ModCtrl},
|
||||
string(byte(ansi.STX)): {Code: 'b', Mod: ModCtrl},
|
||||
string(byte(ansi.ETX)): {Code: 'c', Mod: ModCtrl},
|
||||
string(byte(ansi.EOT)): {Code: 'd', Mod: ModCtrl},
|
||||
string(byte(ansi.ENQ)): {Code: 'e', Mod: ModCtrl},
|
||||
string(byte(ansi.ACK)): {Code: 'f', Mod: ModCtrl},
|
||||
string(byte(ansi.BEL)): {Code: 'g', Mod: ModCtrl},
|
||||
string(byte(ansi.BS)): {Code: 'h', Mod: ModCtrl},
|
||||
string(byte(ansi.HT)): tab,
|
||||
string(byte(ansi.LF)): {Code: 'j', Mod: ModCtrl},
|
||||
string(byte(ansi.VT)): {Code: 'k', Mod: ModCtrl},
|
||||
string(byte(ansi.FF)): {Code: 'l', Mod: ModCtrl},
|
||||
string(byte(ansi.CR)): enter,
|
||||
string(byte(ansi.SO)): {Code: 'n', Mod: ModCtrl},
|
||||
string(byte(ansi.SI)): {Code: 'o', Mod: ModCtrl},
|
||||
string(byte(ansi.DLE)): {Code: 'p', Mod: ModCtrl},
|
||||
string(byte(ansi.DC1)): {Code: 'q', Mod: ModCtrl},
|
||||
string(byte(ansi.DC2)): {Code: 'r', Mod: ModCtrl},
|
||||
string(byte(ansi.DC3)): {Code: 's', Mod: ModCtrl},
|
||||
string(byte(ansi.DC4)): {Code: 't', Mod: ModCtrl},
|
||||
string(byte(ansi.NAK)): {Code: 'u', Mod: ModCtrl},
|
||||
string(byte(ansi.SYN)): {Code: 'v', Mod: ModCtrl},
|
||||
string(byte(ansi.ETB)): {Code: 'w', Mod: ModCtrl},
|
||||
string(byte(ansi.CAN)): {Code: 'x', Mod: ModCtrl},
|
||||
string(byte(ansi.EM)): {Code: 'y', Mod: ModCtrl},
|
||||
string(byte(ansi.SUB)): {Code: 'z', Mod: ModCtrl},
|
||||
string(byte(ansi.ESC)): esc,
|
||||
string(byte(ansi.FS)): {Code: '\\', Mod: ModCtrl},
|
||||
string(byte(ansi.GS)): {Code: ']', Mod: ModCtrl},
|
||||
string(byte(ansi.RS)): {Code: '^', Mod: ModCtrl},
|
||||
string(byte(ansi.US)): {Code: '_', Mod: ModCtrl},
|
||||
|
||||
// Special keys in G0
|
||||
string(byte(ansi.SP)): {Code: KeySpace, Text: " "},
|
||||
string(byte(ansi.DEL)): del,
|
||||
|
||||
// Special keys
|
||||
|
||||
"\x1b[Z": {Code: KeyTab, Mod: ModShift},
|
||||
|
||||
"\x1b[1~": find,
|
||||
"\x1b[2~": {Code: KeyInsert},
|
||||
"\x1b[3~": {Code: KeyDelete},
|
||||
"\x1b[4~": sel,
|
||||
"\x1b[5~": {Code: KeyPgUp},
|
||||
"\x1b[6~": {Code: KeyPgDown},
|
||||
"\x1b[7~": {Code: KeyHome},
|
||||
"\x1b[8~": {Code: KeyEnd},
|
||||
|
||||
// Normal mode
|
||||
"\x1b[A": {Code: KeyUp},
|
||||
"\x1b[B": {Code: KeyDown},
|
||||
"\x1b[C": {Code: KeyRight},
|
||||
"\x1b[D": {Code: KeyLeft},
|
||||
"\x1b[E": {Code: KeyBegin},
|
||||
"\x1b[F": {Code: KeyEnd},
|
||||
"\x1b[H": {Code: KeyHome},
|
||||
"\x1b[P": {Code: KeyF1},
|
||||
"\x1b[Q": {Code: KeyF2},
|
||||
"\x1b[R": {Code: KeyF3},
|
||||
"\x1b[S": {Code: KeyF4},
|
||||
|
||||
// Application Cursor Key Mode (DECCKM)
|
||||
"\x1bOA": {Code: KeyUp},
|
||||
"\x1bOB": {Code: KeyDown},
|
||||
"\x1bOC": {Code: KeyRight},
|
||||
"\x1bOD": {Code: KeyLeft},
|
||||
"\x1bOE": {Code: KeyBegin},
|
||||
"\x1bOF": {Code: KeyEnd},
|
||||
"\x1bOH": {Code: KeyHome},
|
||||
"\x1bOP": {Code: KeyF1},
|
||||
"\x1bOQ": {Code: KeyF2},
|
||||
"\x1bOR": {Code: KeyF3},
|
||||
"\x1bOS": {Code: KeyF4},
|
||||
|
||||
// Keypad Application Mode (DECKPAM)
|
||||
|
||||
"\x1bOM": {Code: KeyKpEnter},
|
||||
"\x1bOX": {Code: KeyKpEqual},
|
||||
"\x1bOj": {Code: KeyKpMultiply},
|
||||
"\x1bOk": {Code: KeyKpPlus},
|
||||
"\x1bOl": {Code: KeyKpComma},
|
||||
"\x1bOm": {Code: KeyKpMinus},
|
||||
"\x1bOn": {Code: KeyKpDecimal},
|
||||
"\x1bOo": {Code: KeyKpDivide},
|
||||
"\x1bOp": {Code: KeyKp0},
|
||||
"\x1bOq": {Code: KeyKp1},
|
||||
"\x1bOr": {Code: KeyKp2},
|
||||
"\x1bOs": {Code: KeyKp3},
|
||||
"\x1bOt": {Code: KeyKp4},
|
||||
"\x1bOu": {Code: KeyKp5},
|
||||
"\x1bOv": {Code: KeyKp6},
|
||||
"\x1bOw": {Code: KeyKp7},
|
||||
"\x1bOx": {Code: KeyKp8},
|
||||
"\x1bOy": {Code: KeyKp9},
|
||||
|
||||
// Function keys
|
||||
|
||||
"\x1b[11~": {Code: KeyF1},
|
||||
"\x1b[12~": {Code: KeyF2},
|
||||
"\x1b[13~": {Code: KeyF3},
|
||||
"\x1b[14~": {Code: KeyF4},
|
||||
"\x1b[15~": {Code: KeyF5},
|
||||
"\x1b[17~": {Code: KeyF6},
|
||||
"\x1b[18~": {Code: KeyF7},
|
||||
"\x1b[19~": {Code: KeyF8},
|
||||
"\x1b[20~": {Code: KeyF9},
|
||||
"\x1b[21~": {Code: KeyF10},
|
||||
"\x1b[23~": {Code: KeyF11},
|
||||
"\x1b[24~": {Code: KeyF12},
|
||||
"\x1b[25~": {Code: KeyF13},
|
||||
"\x1b[26~": {Code: KeyF14},
|
||||
"\x1b[28~": {Code: KeyF15},
|
||||
"\x1b[29~": {Code: KeyF16},
|
||||
"\x1b[31~": {Code: KeyF17},
|
||||
"\x1b[32~": {Code: KeyF18},
|
||||
"\x1b[33~": {Code: KeyF19},
|
||||
"\x1b[34~": {Code: KeyF20},
|
||||
}
|
||||
|
||||
// CSI ~ sequence keys
|
||||
csiTildeKeys := map[string]Key{
|
||||
"1": find, "2": {Code: KeyInsert},
|
||||
"3": {Code: KeyDelete}, "4": sel,
|
||||
"5": {Code: KeyPgUp}, "6": {Code: KeyPgDown},
|
||||
"7": {Code: KeyHome}, "8": {Code: KeyEnd},
|
||||
// There are no 9 and 10 keys
|
||||
"11": {Code: KeyF1}, "12": {Code: KeyF2},
|
||||
"13": {Code: KeyF3}, "14": {Code: KeyF4},
|
||||
"15": {Code: KeyF5}, "17": {Code: KeyF6},
|
||||
"18": {Code: KeyF7}, "19": {Code: KeyF8},
|
||||
"20": {Code: KeyF9}, "21": {Code: KeyF10},
|
||||
"23": {Code: KeyF11}, "24": {Code: KeyF12},
|
||||
"25": {Code: KeyF13}, "26": {Code: KeyF14},
|
||||
"28": {Code: KeyF15}, "29": {Code: KeyF16},
|
||||
"31": {Code: KeyF17}, "32": {Code: KeyF18},
|
||||
"33": {Code: KeyF19}, "34": {Code: KeyF20},
|
||||
}
|
||||
|
||||
// URxvt keys
|
||||
// See https://manpages.ubuntu.com/manpages/trusty/man7/urxvt.7.html#key%20codes
|
||||
table["\x1b[a"] = Key{Code: KeyUp, Mod: ModShift}
|
||||
table["\x1b[b"] = Key{Code: KeyDown, Mod: ModShift}
|
||||
table["\x1b[c"] = Key{Code: KeyRight, Mod: ModShift}
|
||||
table["\x1b[d"] = Key{Code: KeyLeft, Mod: ModShift}
|
||||
table["\x1bOa"] = Key{Code: KeyUp, Mod: ModCtrl}
|
||||
table["\x1bOb"] = Key{Code: KeyDown, Mod: ModCtrl}
|
||||
table["\x1bOc"] = Key{Code: KeyRight, Mod: ModCtrl}
|
||||
table["\x1bOd"] = Key{Code: KeyLeft, Mod: ModCtrl}
|
||||
//nolint:godox
|
||||
// TODO: invistigate if shift-ctrl arrow keys collide with DECCKM keys i.e.
|
||||
// "\x1bOA", "\x1bOB", "\x1bOC", "\x1bOD"
|
||||
|
||||
// URxvt modifier CSI ~ keys
|
||||
for k, v := range csiTildeKeys {
|
||||
key := v
|
||||
// Normal (no modifier) already defined part of VT100/VT200
|
||||
// Shift modifier
|
||||
key.Mod = ModShift
|
||||
table["\x1b["+k+"$"] = key
|
||||
// Ctrl modifier
|
||||
key.Mod = ModCtrl
|
||||
table["\x1b["+k+"^"] = key
|
||||
// Shift-Ctrl modifier
|
||||
key.Mod = ModShift | ModCtrl
|
||||
table["\x1b["+k+"@"] = key
|
||||
}
|
||||
|
||||
// URxvt F keys
|
||||
// Note: Shift + F1-F10 generates F11-F20.
|
||||
// This means Shift + F1 and Shift + F2 will generate F11 and F12, the same
|
||||
// applies to Ctrl + Shift F1 & F2.
|
||||
//
|
||||
// P.S. Don't like this? Blame URxvt, configure your terminal to use
|
||||
// different escapes like XTerm, or switch to a better terminal ¯\_(ツ)_/¯
|
||||
//
|
||||
// See https://manpages.ubuntu.com/manpages/trusty/man7/urxvt.7.html#key%20codes
|
||||
table["\x1b[23$"] = Key{Code: KeyF11, Mod: ModShift}
|
||||
table["\x1b[24$"] = Key{Code: KeyF12, Mod: ModShift}
|
||||
table["\x1b[25$"] = Key{Code: KeyF13, Mod: ModShift}
|
||||
table["\x1b[26$"] = Key{Code: KeyF14, Mod: ModShift}
|
||||
table["\x1b[28$"] = Key{Code: KeyF15, Mod: ModShift}
|
||||
table["\x1b[29$"] = Key{Code: KeyF16, Mod: ModShift}
|
||||
table["\x1b[31$"] = Key{Code: KeyF17, Mod: ModShift}
|
||||
table["\x1b[32$"] = Key{Code: KeyF18, Mod: ModShift}
|
||||
table["\x1b[33$"] = Key{Code: KeyF19, Mod: ModShift}
|
||||
table["\x1b[34$"] = Key{Code: KeyF20, Mod: ModShift}
|
||||
table["\x1b[11^"] = Key{Code: KeyF1, Mod: ModCtrl}
|
||||
table["\x1b[12^"] = Key{Code: KeyF2, Mod: ModCtrl}
|
||||
table["\x1b[13^"] = Key{Code: KeyF3, Mod: ModCtrl}
|
||||
table["\x1b[14^"] = Key{Code: KeyF4, Mod: ModCtrl}
|
||||
table["\x1b[15^"] = Key{Code: KeyF5, Mod: ModCtrl}
|
||||
table["\x1b[17^"] = Key{Code: KeyF6, Mod: ModCtrl}
|
||||
table["\x1b[18^"] = Key{Code: KeyF7, Mod: ModCtrl}
|
||||
table["\x1b[19^"] = Key{Code: KeyF8, Mod: ModCtrl}
|
||||
table["\x1b[20^"] = Key{Code: KeyF9, Mod: ModCtrl}
|
||||
table["\x1b[21^"] = Key{Code: KeyF10, Mod: ModCtrl}
|
||||
table["\x1b[23^"] = Key{Code: KeyF11, Mod: ModCtrl}
|
||||
table["\x1b[24^"] = Key{Code: KeyF12, Mod: ModCtrl}
|
||||
table["\x1b[25^"] = Key{Code: KeyF13, Mod: ModCtrl}
|
||||
table["\x1b[26^"] = Key{Code: KeyF14, Mod: ModCtrl}
|
||||
table["\x1b[28^"] = Key{Code: KeyF15, Mod: ModCtrl}
|
||||
table["\x1b[29^"] = Key{Code: KeyF16, Mod: ModCtrl}
|
||||
table["\x1b[31^"] = Key{Code: KeyF17, Mod: ModCtrl}
|
||||
table["\x1b[32^"] = Key{Code: KeyF18, Mod: ModCtrl}
|
||||
table["\x1b[33^"] = Key{Code: KeyF19, Mod: ModCtrl}
|
||||
table["\x1b[34^"] = Key{Code: KeyF20, Mod: ModCtrl}
|
||||
table["\x1b[23@"] = Key{Code: KeyF11, Mod: ModShift | ModCtrl}
|
||||
table["\x1b[24@"] = Key{Code: KeyF12, Mod: ModShift | ModCtrl}
|
||||
table["\x1b[25@"] = Key{Code: KeyF13, Mod: ModShift | ModCtrl}
|
||||
table["\x1b[26@"] = Key{Code: KeyF14, Mod: ModShift | ModCtrl}
|
||||
table["\x1b[28@"] = Key{Code: KeyF15, Mod: ModShift | ModCtrl}
|
||||
table["\x1b[29@"] = Key{Code: KeyF16, Mod: ModShift | ModCtrl}
|
||||
table["\x1b[31@"] = Key{Code: KeyF17, Mod: ModShift | ModCtrl}
|
||||
table["\x1b[32@"] = Key{Code: KeyF18, Mod: ModShift | ModCtrl}
|
||||
table["\x1b[33@"] = Key{Code: KeyF19, Mod: ModShift | ModCtrl}
|
||||
table["\x1b[34@"] = Key{Code: KeyF20, Mod: ModShift | ModCtrl}
|
||||
|
||||
// Register Alt + <key> combinations
|
||||
// XXX: this must come after URxvt but before XTerm keys to register URxvt
|
||||
// keys with alt modifier
|
||||
tmap := map[string]Key{}
|
||||
for seq, key := range table {
|
||||
key := key
|
||||
key.Mod |= ModAlt
|
||||
key.Text = "" // Clear runes
|
||||
tmap["\x1b"+seq] = key
|
||||
}
|
||||
maps.Copy(table, tmap)
|
||||
|
||||
// XTerm modifiers
|
||||
// These are offset by 1 to be compatible with our Mod type.
|
||||
// See https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-PC-Style-Function-Keys
|
||||
modifiers := []KeyMod{
|
||||
ModShift, // 1
|
||||
ModAlt, // 2
|
||||
ModShift | ModAlt, // 3
|
||||
ModCtrl, // 4
|
||||
ModShift | ModCtrl, // 5
|
||||
ModAlt | ModCtrl, // 6
|
||||
ModShift | ModAlt | ModCtrl, // 7
|
||||
ModMeta, // 8
|
||||
ModMeta | ModShift, // 9
|
||||
ModMeta | ModAlt, // 10
|
||||
ModMeta | ModShift | ModAlt, // 11
|
||||
ModMeta | ModCtrl, // 12
|
||||
ModMeta | ModShift | ModCtrl, // 13
|
||||
ModMeta | ModAlt | ModCtrl, // 14
|
||||
ModMeta | ModShift | ModAlt | ModCtrl, // 15
|
||||
}
|
||||
|
||||
// SS3 keypad function keys
|
||||
ss3FuncKeys := map[string]Key{
|
||||
// These are defined in XTerm
|
||||
// Taken from Foot keymap.h and XTerm modifyOtherKeys
|
||||
// https://codeberg.org/dnkl/foot/src/branch/master/keymap.h
|
||||
"M": {Code: KeyKpEnter}, "X": {Code: KeyKpEqual},
|
||||
"j": {Code: KeyKpMultiply}, "k": {Code: KeyKpPlus},
|
||||
"l": {Code: KeyKpComma}, "m": {Code: KeyKpMinus},
|
||||
"n": {Code: KeyKpDecimal}, "o": {Code: KeyKpDivide},
|
||||
"p": {Code: KeyKp0}, "q": {Code: KeyKp1},
|
||||
"r": {Code: KeyKp2}, "s": {Code: KeyKp3},
|
||||
"t": {Code: KeyKp4}, "u": {Code: KeyKp5},
|
||||
"v": {Code: KeyKp6}, "w": {Code: KeyKp7},
|
||||
"x": {Code: KeyKp8}, "y": {Code: KeyKp9},
|
||||
}
|
||||
|
||||
// XTerm keys
|
||||
csiFuncKeys := map[string]Key{
|
||||
"A": {Code: KeyUp}, "B": {Code: KeyDown},
|
||||
"C": {Code: KeyRight}, "D": {Code: KeyLeft},
|
||||
"E": {Code: KeyBegin}, "F": {Code: KeyEnd},
|
||||
"H": {Code: KeyHome}, "P": {Code: KeyF1},
|
||||
"Q": {Code: KeyF2}, "R": {Code: KeyF3},
|
||||
"S": {Code: KeyF4},
|
||||
}
|
||||
|
||||
// CSI 27 ; <modifier> ; <code> ~ keys defined in XTerm modifyOtherKeys
|
||||
modifyOtherKeys := map[int]Key{
|
||||
ansi.BS: {Code: KeyBackspace},
|
||||
ansi.HT: {Code: KeyTab},
|
||||
ansi.CR: {Code: KeyEnter},
|
||||
ansi.ESC: {Code: KeyEscape},
|
||||
ansi.DEL: {Code: KeyBackspace},
|
||||
}
|
||||
|
||||
for _, m := range modifiers {
|
||||
// XTerm modifier offset +1
|
||||
xtermMod := strconv.Itoa(int(m) + 1)
|
||||
|
||||
// CSI 1 ; <modifier> <func>
|
||||
for k, v := range csiFuncKeys {
|
||||
// Functions always have a leading 1 param
|
||||
seq := "\x1b[1;" + xtermMod + k
|
||||
key := v
|
||||
key.Mod = m
|
||||
table[seq] = key
|
||||
}
|
||||
// SS3 <modifier> <func>
|
||||
for k, v := range ss3FuncKeys {
|
||||
seq := "\x1bO" + xtermMod + k
|
||||
key := v
|
||||
key.Mod = m
|
||||
table[seq] = key
|
||||
}
|
||||
// CSI <number> ; <modifier> ~
|
||||
for k, v := range csiTildeKeys {
|
||||
seq := "\x1b[" + k + ";" + xtermMod + "~"
|
||||
key := v
|
||||
key.Mod = m
|
||||
table[seq] = key
|
||||
}
|
||||
// CSI 27 ; <modifier> ; <code> ~
|
||||
for k, v := range modifyOtherKeys {
|
||||
code := strconv.Itoa(k)
|
||||
seq := "\x1b[27;" + xtermMod + ";" + code + "~"
|
||||
key := v
|
||||
key.Mod = m
|
||||
table[seq] = key
|
||||
}
|
||||
}
|
||||
|
||||
// Register terminfo keys
|
||||
// XXX: this might override keys already registered in table
|
||||
if flags&FlagTerminfo != 0 {
|
||||
titable := buildTerminfoKeys(flags, term)
|
||||
maps.Copy(table, titable)
|
||||
}
|
||||
|
||||
return table
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package input
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/hex"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CapabilityEvent represents a Termcap/Terminfo response event. Termcap
|
||||
// responses are generated by the terminal in response to RequestTermcap
|
||||
// (XTGETTCAP) requests.
|
||||
//
|
||||
// See: https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Operating-System-Commands
|
||||
type CapabilityEvent string
|
||||
|
||||
func parseTermcap(data []byte) CapabilityEvent {
|
||||
// XTGETTCAP
|
||||
if len(data) == 0 {
|
||||
return CapabilityEvent("")
|
||||
}
|
||||
|
||||
var tc strings.Builder
|
||||
split := bytes.Split(data, []byte{';'})
|
||||
for _, s := range split {
|
||||
parts := bytes.SplitN(s, []byte{'='}, 2)
|
||||
if len(parts) == 0 {
|
||||
return CapabilityEvent("")
|
||||
}
|
||||
|
||||
name, err := hex.DecodeString(string(parts[0]))
|
||||
if err != nil || len(name) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
var value []byte
|
||||
if len(parts) > 1 {
|
||||
value, err = hex.DecodeString(string(parts[1]))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if tc.Len() > 0 {
|
||||
tc.WriteByte(';')
|
||||
}
|
||||
tc.WriteString(string(name))
|
||||
if len(value) > 0 {
|
||||
tc.WriteByte('=')
|
||||
tc.WriteString(string(value))
|
||||
}
|
||||
}
|
||||
|
||||
return CapabilityEvent(tc.String())
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
package input
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/xo/terminfo"
|
||||
)
|
||||
|
||||
func buildTerminfoKeys(flags int, term string) map[string]Key {
|
||||
table := make(map[string]Key)
|
||||
ti, _ := terminfo.Load(term)
|
||||
if ti == nil {
|
||||
return table
|
||||
}
|
||||
|
||||
tiTable := defaultTerminfoKeys(flags)
|
||||
|
||||
// Default keys
|
||||
for name, seq := range ti.StringCapsShort() {
|
||||
if !strings.HasPrefix(name, "k") || len(seq) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
if k, ok := tiTable[name]; ok {
|
||||
table[string(seq)] = k
|
||||
}
|
||||
}
|
||||
|
||||
// Extended keys
|
||||
for name, seq := range ti.ExtStringCapsShort() {
|
||||
if !strings.HasPrefix(name, "k") || len(seq) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
if k, ok := tiTable[name]; ok {
|
||||
table[string(seq)] = k
|
||||
}
|
||||
}
|
||||
|
||||
return table
|
||||
}
|
||||
|
||||
// This returns a map of terminfo keys to key events. It's a mix of ncurses
|
||||
// terminfo default and user-defined key capabilities.
|
||||
// Upper-case caps that are defined in the default terminfo database are
|
||||
// - kNXT
|
||||
// - kPRV
|
||||
// - kHOM
|
||||
// - kEND
|
||||
// - kDC
|
||||
// - kIC
|
||||
// - kLFT
|
||||
// - kRIT
|
||||
//
|
||||
// See https://man7.org/linux/man-pages/man5/terminfo.5.html
|
||||
// See https://github.com/mirror/ncurses/blob/master/include/Caps-ncurses
|
||||
func defaultTerminfoKeys(flags int) map[string]Key {
|
||||
keys := map[string]Key{
|
||||
"kcuu1": {Code: KeyUp},
|
||||
"kUP": {Code: KeyUp, Mod: ModShift},
|
||||
"kUP3": {Code: KeyUp, Mod: ModAlt},
|
||||
"kUP4": {Code: KeyUp, Mod: ModShift | ModAlt},
|
||||
"kUP5": {Code: KeyUp, Mod: ModCtrl},
|
||||
"kUP6": {Code: KeyUp, Mod: ModShift | ModCtrl},
|
||||
"kUP7": {Code: KeyUp, Mod: ModAlt | ModCtrl},
|
||||
"kUP8": {Code: KeyUp, Mod: ModShift | ModAlt | ModCtrl},
|
||||
"kcud1": {Code: KeyDown},
|
||||
"kDN": {Code: KeyDown, Mod: ModShift},
|
||||
"kDN3": {Code: KeyDown, Mod: ModAlt},
|
||||
"kDN4": {Code: KeyDown, Mod: ModShift | ModAlt},
|
||||
"kDN5": {Code: KeyDown, Mod: ModCtrl},
|
||||
"kDN7": {Code: KeyDown, Mod: ModAlt | ModCtrl},
|
||||
"kDN6": {Code: KeyDown, Mod: ModShift | ModCtrl},
|
||||
"kDN8": {Code: KeyDown, Mod: ModShift | ModAlt | ModCtrl},
|
||||
"kcub1": {Code: KeyLeft},
|
||||
"kLFT": {Code: KeyLeft, Mod: ModShift},
|
||||
"kLFT3": {Code: KeyLeft, Mod: ModAlt},
|
||||
"kLFT4": {Code: KeyLeft, Mod: ModShift | ModAlt},
|
||||
"kLFT5": {Code: KeyLeft, Mod: ModCtrl},
|
||||
"kLFT6": {Code: KeyLeft, Mod: ModShift | ModCtrl},
|
||||
"kLFT7": {Code: KeyLeft, Mod: ModAlt | ModCtrl},
|
||||
"kLFT8": {Code: KeyLeft, Mod: ModShift | ModAlt | ModCtrl},
|
||||
"kcuf1": {Code: KeyRight},
|
||||
"kRIT": {Code: KeyRight, Mod: ModShift},
|
||||
"kRIT3": {Code: KeyRight, Mod: ModAlt},
|
||||
"kRIT4": {Code: KeyRight, Mod: ModShift | ModAlt},
|
||||
"kRIT5": {Code: KeyRight, Mod: ModCtrl},
|
||||
"kRIT6": {Code: KeyRight, Mod: ModShift | ModCtrl},
|
||||
"kRIT7": {Code: KeyRight, Mod: ModAlt | ModCtrl},
|
||||
"kRIT8": {Code: KeyRight, Mod: ModShift | ModAlt | ModCtrl},
|
||||
"kich1": {Code: KeyInsert},
|
||||
"kIC": {Code: KeyInsert, Mod: ModShift},
|
||||
"kIC3": {Code: KeyInsert, Mod: ModAlt},
|
||||
"kIC4": {Code: KeyInsert, Mod: ModShift | ModAlt},
|
||||
"kIC5": {Code: KeyInsert, Mod: ModCtrl},
|
||||
"kIC6": {Code: KeyInsert, Mod: ModShift | ModCtrl},
|
||||
"kIC7": {Code: KeyInsert, Mod: ModAlt | ModCtrl},
|
||||
"kIC8": {Code: KeyInsert, Mod: ModShift | ModAlt | ModCtrl},
|
||||
"kdch1": {Code: KeyDelete},
|
||||
"kDC": {Code: KeyDelete, Mod: ModShift},
|
||||
"kDC3": {Code: KeyDelete, Mod: ModAlt},
|
||||
"kDC4": {Code: KeyDelete, Mod: ModShift | ModAlt},
|
||||
"kDC5": {Code: KeyDelete, Mod: ModCtrl},
|
||||
"kDC6": {Code: KeyDelete, Mod: ModShift | ModCtrl},
|
||||
"kDC7": {Code: KeyDelete, Mod: ModAlt | ModCtrl},
|
||||
"kDC8": {Code: KeyDelete, Mod: ModShift | ModAlt | ModCtrl},
|
||||
"khome": {Code: KeyHome},
|
||||
"kHOM": {Code: KeyHome, Mod: ModShift},
|
||||
"kHOM3": {Code: KeyHome, Mod: ModAlt},
|
||||
"kHOM4": {Code: KeyHome, Mod: ModShift | ModAlt},
|
||||
"kHOM5": {Code: KeyHome, Mod: ModCtrl},
|
||||
"kHOM6": {Code: KeyHome, Mod: ModShift | ModCtrl},
|
||||
"kHOM7": {Code: KeyHome, Mod: ModAlt | ModCtrl},
|
||||
"kHOM8": {Code: KeyHome, Mod: ModShift | ModAlt | ModCtrl},
|
||||
"kend": {Code: KeyEnd},
|
||||
"kEND": {Code: KeyEnd, Mod: ModShift},
|
||||
"kEND3": {Code: KeyEnd, Mod: ModAlt},
|
||||
"kEND4": {Code: KeyEnd, Mod: ModShift | ModAlt},
|
||||
"kEND5": {Code: KeyEnd, Mod: ModCtrl},
|
||||
"kEND6": {Code: KeyEnd, Mod: ModShift | ModCtrl},
|
||||
"kEND7": {Code: KeyEnd, Mod: ModAlt | ModCtrl},
|
||||
"kEND8": {Code: KeyEnd, Mod: ModShift | ModAlt | ModCtrl},
|
||||
"kpp": {Code: KeyPgUp},
|
||||
"kprv": {Code: KeyPgUp},
|
||||
"kPRV": {Code: KeyPgUp, Mod: ModShift},
|
||||
"kPRV3": {Code: KeyPgUp, Mod: ModAlt},
|
||||
"kPRV4": {Code: KeyPgUp, Mod: ModShift | ModAlt},
|
||||
"kPRV5": {Code: KeyPgUp, Mod: ModCtrl},
|
||||
"kPRV6": {Code: KeyPgUp, Mod: ModShift | ModCtrl},
|
||||
"kPRV7": {Code: KeyPgUp, Mod: ModAlt | ModCtrl},
|
||||
"kPRV8": {Code: KeyPgUp, Mod: ModShift | ModAlt | ModCtrl},
|
||||
"knp": {Code: KeyPgDown},
|
||||
"knxt": {Code: KeyPgDown},
|
||||
"kNXT": {Code: KeyPgDown, Mod: ModShift},
|
||||
"kNXT3": {Code: KeyPgDown, Mod: ModAlt},
|
||||
"kNXT4": {Code: KeyPgDown, Mod: ModShift | ModAlt},
|
||||
"kNXT5": {Code: KeyPgDown, Mod: ModCtrl},
|
||||
"kNXT6": {Code: KeyPgDown, Mod: ModShift | ModCtrl},
|
||||
"kNXT7": {Code: KeyPgDown, Mod: ModAlt | ModCtrl},
|
||||
"kNXT8": {Code: KeyPgDown, Mod: ModShift | ModAlt | ModCtrl},
|
||||
|
||||
"kbs": {Code: KeyBackspace},
|
||||
"kcbt": {Code: KeyTab, Mod: ModShift},
|
||||
|
||||
// Function keys
|
||||
// This only includes the first 12 function keys. The rest are treated
|
||||
// as modifiers of the first 12.
|
||||
// Take a look at XTerm modifyFunctionKeys
|
||||
//
|
||||
// XXX: To use unambiguous function keys, use fixterms or kitty clipboard.
|
||||
//
|
||||
// See https://invisible-island.net/xterm/manpage/xterm.html#VT100-Widget-Resources:modifyFunctionKeys
|
||||
// See https://invisible-island.net/xterm/terminfo.html
|
||||
|
||||
"kf1": {Code: KeyF1},
|
||||
"kf2": {Code: KeyF2},
|
||||
"kf3": {Code: KeyF3},
|
||||
"kf4": {Code: KeyF4},
|
||||
"kf5": {Code: KeyF5},
|
||||
"kf6": {Code: KeyF6},
|
||||
"kf7": {Code: KeyF7},
|
||||
"kf8": {Code: KeyF8},
|
||||
"kf9": {Code: KeyF9},
|
||||
"kf10": {Code: KeyF10},
|
||||
"kf11": {Code: KeyF11},
|
||||
"kf12": {Code: KeyF12},
|
||||
"kf13": {Code: KeyF1, Mod: ModShift},
|
||||
"kf14": {Code: KeyF2, Mod: ModShift},
|
||||
"kf15": {Code: KeyF3, Mod: ModShift},
|
||||
"kf16": {Code: KeyF4, Mod: ModShift},
|
||||
"kf17": {Code: KeyF5, Mod: ModShift},
|
||||
"kf18": {Code: KeyF6, Mod: ModShift},
|
||||
"kf19": {Code: KeyF7, Mod: ModShift},
|
||||
"kf20": {Code: KeyF8, Mod: ModShift},
|
||||
"kf21": {Code: KeyF9, Mod: ModShift},
|
||||
"kf22": {Code: KeyF10, Mod: ModShift},
|
||||
"kf23": {Code: KeyF11, Mod: ModShift},
|
||||
"kf24": {Code: KeyF12, Mod: ModShift},
|
||||
"kf25": {Code: KeyF1, Mod: ModCtrl},
|
||||
"kf26": {Code: KeyF2, Mod: ModCtrl},
|
||||
"kf27": {Code: KeyF3, Mod: ModCtrl},
|
||||
"kf28": {Code: KeyF4, Mod: ModCtrl},
|
||||
"kf29": {Code: KeyF5, Mod: ModCtrl},
|
||||
"kf30": {Code: KeyF6, Mod: ModCtrl},
|
||||
"kf31": {Code: KeyF7, Mod: ModCtrl},
|
||||
"kf32": {Code: KeyF8, Mod: ModCtrl},
|
||||
"kf33": {Code: KeyF9, Mod: ModCtrl},
|
||||
"kf34": {Code: KeyF10, Mod: ModCtrl},
|
||||
"kf35": {Code: KeyF11, Mod: ModCtrl},
|
||||
"kf36": {Code: KeyF12, Mod: ModCtrl},
|
||||
"kf37": {Code: KeyF1, Mod: ModShift | ModCtrl},
|
||||
"kf38": {Code: KeyF2, Mod: ModShift | ModCtrl},
|
||||
"kf39": {Code: KeyF3, Mod: ModShift | ModCtrl},
|
||||
"kf40": {Code: KeyF4, Mod: ModShift | ModCtrl},
|
||||
"kf41": {Code: KeyF5, Mod: ModShift | ModCtrl},
|
||||
"kf42": {Code: KeyF6, Mod: ModShift | ModCtrl},
|
||||
"kf43": {Code: KeyF7, Mod: ModShift | ModCtrl},
|
||||
"kf44": {Code: KeyF8, Mod: ModShift | ModCtrl},
|
||||
"kf45": {Code: KeyF9, Mod: ModShift | ModCtrl},
|
||||
"kf46": {Code: KeyF10, Mod: ModShift | ModCtrl},
|
||||
"kf47": {Code: KeyF11, Mod: ModShift | ModCtrl},
|
||||
"kf48": {Code: KeyF12, Mod: ModShift | ModCtrl},
|
||||
"kf49": {Code: KeyF1, Mod: ModAlt},
|
||||
"kf50": {Code: KeyF2, Mod: ModAlt},
|
||||
"kf51": {Code: KeyF3, Mod: ModAlt},
|
||||
"kf52": {Code: KeyF4, Mod: ModAlt},
|
||||
"kf53": {Code: KeyF5, Mod: ModAlt},
|
||||
"kf54": {Code: KeyF6, Mod: ModAlt},
|
||||
"kf55": {Code: KeyF7, Mod: ModAlt},
|
||||
"kf56": {Code: KeyF8, Mod: ModAlt},
|
||||
"kf57": {Code: KeyF9, Mod: ModAlt},
|
||||
"kf58": {Code: KeyF10, Mod: ModAlt},
|
||||
"kf59": {Code: KeyF11, Mod: ModAlt},
|
||||
"kf60": {Code: KeyF12, Mod: ModAlt},
|
||||
"kf61": {Code: KeyF1, Mod: ModShift | ModAlt},
|
||||
"kf62": {Code: KeyF2, Mod: ModShift | ModAlt},
|
||||
"kf63": {Code: KeyF3, Mod: ModShift | ModAlt},
|
||||
}
|
||||
|
||||
// Preserve F keys from F13 to F63 instead of using them for F-keys
|
||||
// modifiers.
|
||||
if flags&FlagFKeys != 0 {
|
||||
keys["kf13"] = Key{Code: KeyF13}
|
||||
keys["kf14"] = Key{Code: KeyF14}
|
||||
keys["kf15"] = Key{Code: KeyF15}
|
||||
keys["kf16"] = Key{Code: KeyF16}
|
||||
keys["kf17"] = Key{Code: KeyF17}
|
||||
keys["kf18"] = Key{Code: KeyF18}
|
||||
keys["kf19"] = Key{Code: KeyF19}
|
||||
keys["kf20"] = Key{Code: KeyF20}
|
||||
keys["kf21"] = Key{Code: KeyF21}
|
||||
keys["kf22"] = Key{Code: KeyF22}
|
||||
keys["kf23"] = Key{Code: KeyF23}
|
||||
keys["kf24"] = Key{Code: KeyF24}
|
||||
keys["kf25"] = Key{Code: KeyF25}
|
||||
keys["kf26"] = Key{Code: KeyF26}
|
||||
keys["kf27"] = Key{Code: KeyF27}
|
||||
keys["kf28"] = Key{Code: KeyF28}
|
||||
keys["kf29"] = Key{Code: KeyF29}
|
||||
keys["kf30"] = Key{Code: KeyF30}
|
||||
keys["kf31"] = Key{Code: KeyF31}
|
||||
keys["kf32"] = Key{Code: KeyF32}
|
||||
keys["kf33"] = Key{Code: KeyF33}
|
||||
keys["kf34"] = Key{Code: KeyF34}
|
||||
keys["kf35"] = Key{Code: KeyF35}
|
||||
keys["kf36"] = Key{Code: KeyF36}
|
||||
keys["kf37"] = Key{Code: KeyF37}
|
||||
keys["kf38"] = Key{Code: KeyF38}
|
||||
keys["kf39"] = Key{Code: KeyF39}
|
||||
keys["kf40"] = Key{Code: KeyF40}
|
||||
keys["kf41"] = Key{Code: KeyF41}
|
||||
keys["kf42"] = Key{Code: KeyF42}
|
||||
keys["kf43"] = Key{Code: KeyF43}
|
||||
keys["kf44"] = Key{Code: KeyF44}
|
||||
keys["kf45"] = Key{Code: KeyF45}
|
||||
keys["kf46"] = Key{Code: KeyF46}
|
||||
keys["kf47"] = Key{Code: KeyF47}
|
||||
keys["kf48"] = Key{Code: KeyF48}
|
||||
keys["kf49"] = Key{Code: KeyF49}
|
||||
keys["kf50"] = Key{Code: KeyF50}
|
||||
keys["kf51"] = Key{Code: KeyF51}
|
||||
keys["kf52"] = Key{Code: KeyF52}
|
||||
keys["kf53"] = Key{Code: KeyF53}
|
||||
keys["kf54"] = Key{Code: KeyF54}
|
||||
keys["kf55"] = Key{Code: KeyF55}
|
||||
keys["kf56"] = Key{Code: KeyF56}
|
||||
keys["kf57"] = Key{Code: KeyF57}
|
||||
keys["kf58"] = Key{Code: KeyF58}
|
||||
keys["kf59"] = Key{Code: KeyF59}
|
||||
keys["kf60"] = Key{Code: KeyF60}
|
||||
keys["kf61"] = Key{Code: KeyF61}
|
||||
keys["kf62"] = Key{Code: KeyF62}
|
||||
keys["kf63"] = Key{Code: KeyF63}
|
||||
}
|
||||
|
||||
return keys
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package input
|
||||
|
||||
import (
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
)
|
||||
|
||||
func parseXTermModifyOtherKeys(params ansi.Params) Event {
|
||||
// XTerm modify other keys starts with ESC [ 27 ; <modifier> ; <code> ~
|
||||
xmod, _, _ := params.Param(1, 1)
|
||||
xrune, _, _ := params.Param(2, 1)
|
||||
mod := KeyMod(xmod - 1)
|
||||
r := rune(xrune)
|
||||
|
||||
switch r {
|
||||
case ansi.BS:
|
||||
return KeyPressEvent{Mod: mod, Code: KeyBackspace}
|
||||
case ansi.HT:
|
||||
return KeyPressEvent{Mod: mod, Code: KeyTab}
|
||||
case ansi.CR:
|
||||
return KeyPressEvent{Mod: mod, Code: KeyEnter}
|
||||
case ansi.ESC:
|
||||
return KeyPressEvent{Mod: mod, Code: KeyEscape}
|
||||
case ansi.DEL:
|
||||
return KeyPressEvent{Mod: mod, Code: KeyBackspace}
|
||||
}
|
||||
|
||||
// CSI 27 ; <modifier> ; <code> ~ keys defined in XTerm modifyOtherKeys
|
||||
k := KeyPressEvent{Code: r, Mod: mod}
|
||||
if k.Mod <= ModShift {
|
||||
k.Text = string(r)
|
||||
}
|
||||
|
||||
return k
|
||||
}
|
||||
|
||||
// TerminalVersionEvent is a message that represents the terminal version.
|
||||
type TerminalVersionEvent string
|
||||
|
||||
// ModifyOtherKeysEvent represents a modifyOtherKeys event.
|
||||
//
|
||||
// 0: disable
|
||||
// 1: enable mode 1
|
||||
// 2: enable mode 2
|
||||
//
|
||||
// See: https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Functions-using-CSI-_-ordered-by-the-final-character_s_
|
||||
// See: https://invisible-island.net/xterm/manpage/xterm.html#VT100-Widget-Resources:modifyOtherKeys
|
||||
type ModifyOtherKeysEvent uint8
|
||||
@@ -12,30 +12,36 @@ import (
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
"github.com/sst/opencode-sdk-go"
|
||||
"github.com/sst/opencode/internal/clipboard"
|
||||
"github.com/sst/opencode/internal/commands"
|
||||
"github.com/sst/opencode/internal/components/toast"
|
||||
"github.com/sst/opencode/internal/config"
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
"github.com/sst/opencode/internal/util"
|
||||
"golang.design/x/clipboard"
|
||||
)
|
||||
|
||||
type App struct {
|
||||
Info opencode.App
|
||||
Version string
|
||||
StatePath string
|
||||
Config *opencode.Config
|
||||
Client *opencode.Client
|
||||
State *config.State
|
||||
Provider *opencode.Provider
|
||||
Model *opencode.Model
|
||||
Session *opencode.Session
|
||||
Messages []opencode.MessageUnion
|
||||
Commands commands.CommandRegistry
|
||||
InitialModel *string
|
||||
InitialPrompt *string
|
||||
compactCancel context.CancelFunc
|
||||
Info opencode.App
|
||||
Modes []opencode.Mode
|
||||
Providers []opencode.Provider
|
||||
Version string
|
||||
StatePath string
|
||||
Config *opencode.Config
|
||||
Client *opencode.Client
|
||||
State *config.State
|
||||
ModeIndex int
|
||||
Mode *opencode.Mode
|
||||
Provider *opencode.Provider
|
||||
Model *opencode.Model
|
||||
Session *opencode.Session
|
||||
Messages []opencode.MessageUnion
|
||||
Commands commands.CommandRegistry
|
||||
InitialModel *string
|
||||
InitialPrompt *string
|
||||
IntitialMode *string
|
||||
compactCancel context.CancelFunc
|
||||
IsLeaderSequence bool
|
||||
}
|
||||
|
||||
type SessionSelectedMsg = *opencode.Session
|
||||
@@ -50,6 +56,9 @@ type SendMsg struct {
|
||||
Text string
|
||||
Attachments []opencode.FilePartParam
|
||||
}
|
||||
type SetEditorContentMsg struct {
|
||||
Text string
|
||||
}
|
||||
type OptimisticMessageAddedMsg struct {
|
||||
Message opencode.MessageUnion
|
||||
}
|
||||
@@ -61,9 +70,11 @@ func New(
|
||||
ctx context.Context,
|
||||
version string,
|
||||
appInfo opencode.App,
|
||||
modes []opencode.Mode,
|
||||
httpClient *opencode.Client,
|
||||
model *string,
|
||||
prompt *string,
|
||||
initialModel *string,
|
||||
initialPrompt *string,
|
||||
initialMode *string,
|
||||
) (*App, error) {
|
||||
util.RootPath = appInfo.Path.Root
|
||||
util.CwdPath = appInfo.Path.Cwd
|
||||
@@ -84,14 +95,36 @@ func New(
|
||||
config.SaveState(appStatePath, appState)
|
||||
}
|
||||
|
||||
if appState.ModeModel == nil {
|
||||
appState.ModeModel = make(map[string]config.ModeModel)
|
||||
}
|
||||
|
||||
if configInfo.Theme != "" {
|
||||
appState.Theme = configInfo.Theme
|
||||
}
|
||||
|
||||
if configInfo.Model != "" {
|
||||
splits := strings.Split(configInfo.Model, "/")
|
||||
appState.Provider = splits[0]
|
||||
appState.Model = strings.Join(splits[1:], "/")
|
||||
var modeIndex int
|
||||
var mode *opencode.Mode
|
||||
modeName := "build"
|
||||
if appState.Mode != "" {
|
||||
modeName = appState.Mode
|
||||
}
|
||||
if initialMode != nil && *initialMode != "" {
|
||||
modeName = *initialMode
|
||||
}
|
||||
for i, m := range modes {
|
||||
if m.Name == modeName {
|
||||
modeIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
mode = &modes[modeIndex]
|
||||
|
||||
if mode.Model.ModelID != "" {
|
||||
appState.ModeModel[mode.Name] = config.ModeModel{
|
||||
ProviderID: mode.Model.ProviderID,
|
||||
ModelID: mode.Model.ModelID,
|
||||
}
|
||||
}
|
||||
|
||||
if err := theme.LoadThemesFromDirectories(
|
||||
@@ -116,16 +149,20 @@ func New(
|
||||
|
||||
app := &App{
|
||||
Info: appInfo,
|
||||
Modes: modes,
|
||||
Version: version,
|
||||
StatePath: appStatePath,
|
||||
Config: configInfo,
|
||||
State: appState,
|
||||
Client: httpClient,
|
||||
ModeIndex: modeIndex,
|
||||
Mode: mode,
|
||||
Session: &opencode.Session{},
|
||||
Messages: []opencode.MessageUnion{},
|
||||
Commands: commands.LoadFromConfig(configInfo),
|
||||
InitialModel: model,
|
||||
InitialPrompt: prompt,
|
||||
InitialModel: initialModel,
|
||||
InitialPrompt: initialPrompt,
|
||||
IntitialMode: initialMode,
|
||||
}
|
||||
|
||||
return app, nil
|
||||
@@ -159,6 +196,45 @@ func (a *App) SetClipboard(text string) tea.Cmd {
|
||||
return tea.Sequence(cmds...)
|
||||
}
|
||||
|
||||
func (a *App) SwitchMode() (*App, tea.Cmd) {
|
||||
a.ModeIndex++
|
||||
if a.ModeIndex >= len(a.Modes) {
|
||||
a.ModeIndex = 0
|
||||
}
|
||||
a.Mode = &a.Modes[a.ModeIndex]
|
||||
|
||||
modelID := a.Mode.Model.ModelID
|
||||
providerID := a.Mode.Model.ProviderID
|
||||
if modelID == "" {
|
||||
if model, ok := a.State.ModeModel[a.Mode.Name]; ok {
|
||||
modelID = model.ModelID
|
||||
providerID = model.ProviderID
|
||||
}
|
||||
}
|
||||
|
||||
if modelID != "" {
|
||||
for _, provider := range a.Providers {
|
||||
if provider.ID == providerID {
|
||||
a.Provider = &provider
|
||||
for _, model := range provider.Models {
|
||||
if model.ID == modelID {
|
||||
a.Model = &model
|
||||
break
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
a.State.Mode = a.Mode.Name
|
||||
|
||||
return a, func() tea.Msg {
|
||||
a.SaveState()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) InitializeProvider() tea.Cmd {
|
||||
providersResponse, err := a.Client.Config.Providers(context.Background())
|
||||
if err != nil {
|
||||
@@ -195,6 +271,14 @@ func (a *App) InitializeProvider() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
a.Providers = providers
|
||||
|
||||
// retains backwards compatibility with old state format
|
||||
if model, ok := a.State.ModeModel[a.State.Mode]; ok {
|
||||
a.State.Provider = model.ProviderID
|
||||
a.State.Model = model.ModelID
|
||||
}
|
||||
|
||||
var currentProvider *opencode.Provider
|
||||
var currentModel *opencode.Model
|
||||
for _, provider := range providers {
|
||||
@@ -319,10 +403,14 @@ func (a *App) CompactSession(ctx context.Context) tea.Cmd {
|
||||
a.compactCancel = nil
|
||||
}()
|
||||
|
||||
_, err := a.Client.Session.Summarize(compactCtx, a.Session.ID, opencode.SessionSummarizeParams{
|
||||
ProviderID: opencode.F(a.Provider.ID),
|
||||
ModelID: opencode.F(a.Model.ID),
|
||||
})
|
||||
_, err := a.Client.Session.Summarize(
|
||||
compactCtx,
|
||||
a.Session.ID,
|
||||
opencode.SessionSummarizeParams{
|
||||
ProviderID: opencode.F(a.Provider.ID),
|
||||
ModelID: opencode.F(a.Model.ID),
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
if compactCtx.Err() != context.Canceled {
|
||||
slog.Error("Failed to compact session", "error", err)
|
||||
@@ -414,6 +502,7 @@ func (a *App) SendChatMessage(
|
||||
Parts: opencode.F(parts),
|
||||
ProviderID: opencode.F(a.Provider.ID),
|
||||
ModelID: opencode.F(a.Model.ID),
|
||||
Mode: opencode.F(a.Mode.Name),
|
||||
})
|
||||
if err != nil {
|
||||
errormsg := fmt.Sprintf("failed to send message: %v", err)
|
||||
|
||||
@@ -53,7 +53,7 @@ clipboard data is changed, use the watcher API:
|
||||
println(string(data))
|
||||
}
|
||||
*/
|
||||
package clipboard // import "golang.design/x/clipboard"
|
||||
package clipboard
|
||||
|
||||
import (
|
||||
"context"
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
//
|
||||
// Written by Changkun Ou <changkun.de>
|
||||
|
||||
//go:build darwin && !ios
|
||||
//go:build darwin
|
||||
|
||||
package clipboard
|
||||
|
||||
+28
-3
@@ -4,7 +4,7 @@
|
||||
//
|
||||
// Written by Changkun Ou <changkun.de>
|
||||
|
||||
//go:build linux && !android
|
||||
//go:build linux
|
||||
|
||||
package clipboard
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -72,11 +73,15 @@ func initialize() error {
|
||||
clipboardTools[i].available = true
|
||||
if selectedTool < 0 {
|
||||
selectedTool = i
|
||||
slog.Debug("Clipboard tool found", "tool", tool.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if selectedTool < 0 {
|
||||
slog.Warn(
|
||||
"No clipboard utility found on system. Copy/paste functionality will be disabled. See https://opencode.ai/docs/troubleshooting/ for more information.",
|
||||
)
|
||||
return fmt.Errorf(`%w: No clipboard utility found. Install one of the following:
|
||||
|
||||
For X11 systems:
|
||||
@@ -98,6 +103,12 @@ If running in a headless environment, you may also need:
|
||||
}
|
||||
|
||||
func read(t Format) (buf []byte, err error) {
|
||||
// Ensure clipboard is initialized before attempting to read
|
||||
if err := initialize(); err != nil {
|
||||
slog.Debug("Clipboard read failed: not initialized", "error", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
toolMutex.Lock()
|
||||
tool := clipboardTools[selectedTool]
|
||||
toolMutex.Unlock()
|
||||
@@ -129,7 +140,8 @@ func readText(tool struct {
|
||||
// xclip returns error when clipboard doesn't contain requested type
|
||||
checkCmd := exec.Command("xclip", "-selection", "clipboard", "-t", "TARGETS", "-o")
|
||||
targets, _ := checkCmd.Output()
|
||||
if bytes.Contains(targets, []byte("image/png")) && !bytes.Contains(targets, []byte("UTF8_STRING")) {
|
||||
if bytes.Contains(targets, []byte("image/png")) &&
|
||||
!bytes.Contains(targets, []byte("UTF8_STRING")) {
|
||||
return nil, errUnavailable
|
||||
}
|
||||
}
|
||||
@@ -159,7 +171,8 @@ func readImage(tool struct {
|
||||
}
|
||||
|
||||
// Verify it's PNG data
|
||||
if len(out) < 8 || !bytes.Equal(out[:8], []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}) {
|
||||
if len(out) < 8 ||
|
||||
!bytes.Equal(out[:8], []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}) {
|
||||
return nil, errUnavailable
|
||||
}
|
||||
|
||||
@@ -167,6 +180,11 @@ func readImage(tool struct {
|
||||
}
|
||||
|
||||
func write(t Format, buf []byte) (<-chan struct{}, error) {
|
||||
// Ensure clipboard is initialized before attempting to write
|
||||
if err := initialize(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
toolMutex.Lock()
|
||||
tool := clipboardTools[selectedTool]
|
||||
toolMutex.Unlock()
|
||||
@@ -230,6 +248,13 @@ func write(t Format, buf []byte) (<-chan struct{}, error) {
|
||||
|
||||
func watch(ctx context.Context, t Format) <-chan []byte {
|
||||
recv := make(chan []byte, 1)
|
||||
|
||||
// Ensure clipboard is initialized before starting watch
|
||||
if err := initialize(); err != nil {
|
||||
close(recv)
|
||||
return recv
|
||||
}
|
||||
|
||||
ti := time.NewTicker(time.Second)
|
||||
|
||||
// Get initial clipboard content
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user