├── go.mod ├── go.sum ├── server ├── static │ ├── utility.js │ ├── index.html │ ├── styles.css │ ├── app.js │ ├── neovim.svg │ ├── neovim-inactive.svg │ ├── client.js │ └── renderer.js └── server.go ├── main.go ├── .gitignore ├── LICENSE ├── .github └── workflows │ └── go.yml └── README.md /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/Kraust/nvim-server 2 | 3 | go 1.24.6 4 | 5 | require ( 6 | github.com/gorilla/websocket v1.5.3 // indirect 7 | github.com/neovim/go-client v1.2.1 // indirect 8 | ) 9 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= 2 | github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 3 | github.com/neovim/go-client v1.2.1 h1:kl3PgYgbnBfvaIoGYi3ojyXH0ouY6dJY/rYUCssZKqI= 4 | github.com/neovim/go-client v1.2.1/go.mod h1:EeqCP3z1vJd70JTaH/KXz9RMZ/nIgEFveX83hYnh/7c= 5 | -------------------------------------------------------------------------------- /server/static/utility.js: -------------------------------------------------------------------------------- 1 | function getUrlParameter(name) { 2 | const urlParams = new URLSearchParams(window.location.search); 3 | return urlParams.get(name); 4 | } 5 | 6 | function isValidServerAddress(address) { 7 | const pattern = /^[a-zA-Z0-9.-]+:\d+$/; 8 | return pattern.test(address); 9 | } 10 | 11 | if (typeof module !== "undefined" && module.exports) { 12 | module.exports = { getUrlParameter, isValidServerAddress }; 13 | } 14 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "github.com/Kraust/nvim-server/server" 7 | "log" 8 | "os" 9 | ) 10 | 11 | var ( 12 | version = "dev" 13 | 14 | f_address = flag.String("address", "127.0.0.1:9998", "Specifies the address to bind the server to.") 15 | f_version = flag.Bool("version", false, "Show version information and exit.") 16 | ) 17 | 18 | func main() { 19 | flag.Parse() 20 | 21 | if *f_version { 22 | fmt.Printf("nvim-server %s\n", version) 23 | os.Exit(0) 24 | } 25 | 26 | err := server.Serve(*f_address) 27 | if err != nil { 28 | log.Fatalf("%s", err) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # If you prefer the allow list template instead of the deny list, see community template: 2 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 3 | # 4 | # Binaries for programs and plugins 5 | *.exe 6 | *.exe~ 7 | *.dll 8 | *.so 9 | *.dylib 10 | 11 | # Test binary, built with `go test -c` 12 | *.test 13 | 14 | # Code coverage profiles and other test artifacts 15 | *.out 16 | coverage.* 17 | *.coverprofile 18 | profile.cov 19 | 20 | # Dependency directories (remove the comment below to include it) 21 | # vendor/ 22 | 23 | # Go workspace file 24 | go.work 25 | go.work.sum 26 | 27 | # env file 28 | .env 29 | 30 | # Editor/IDE 31 | # .idea/ 32 | # .vscode/ 33 | 34 | # FIXME: 35 | nvim-server 36 | 37 | -------------------------------------------------------------------------------- /server/static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Neovim Server 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 |
15 |
16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Kraust 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /server/static/styles.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | font-family: Arial, sans-serif; 5 | background: #1a1a1a; 6 | color: #fff; 7 | height: 100vh; 8 | display: flex; 9 | flex-direction: column; 10 | overflow: hidden; 11 | } 12 | 13 | #connection-form { 14 | flex-shrink: 0; 15 | margin: 20px; 16 | padding: 10px; 17 | background: #2a2a2a; 18 | border-radius: 5px; 19 | transition: opacity 0.3s ease-out; 20 | } 21 | 22 | #terminal { 23 | flex: 1; 24 | background: #000; 25 | display: none; 26 | cursor: text; 27 | margin: 0; 28 | padding: 0; 29 | border: none; 30 | outline: none; 31 | transition: all 0.3s ease-out; 32 | transform: translateZ(0); 33 | will-change: transform; 34 | backface-visibility: hidden; 35 | perspective: 1000; 36 | } 37 | 38 | #terminal.connected { 39 | display: block; 40 | position: fixed; 41 | top: 0; 42 | left: 0; 43 | width: 100vw; 44 | height: 100vh; 45 | margin: 0; 46 | padding: 0; 47 | border: none; 48 | } 49 | 50 | #terminal:focus { 51 | outline: none; 52 | } 53 | 54 | #connection-form input { 55 | padding: 8px; 56 | margin-right: 10px; 57 | border: 1px solid #555; 58 | background: #333; 59 | color: #fff; 60 | border-radius: 3px; 61 | } 62 | 63 | #connection-form button { 64 | padding: 8px 16px; 65 | background: #007acc; 66 | color: white; 67 | border: none; 68 | border-radius: 3px; 69 | cursor: pointer; 70 | } 71 | 72 | #connection-form button:hover { 73 | background: #005a9e; 74 | } 75 | 76 | #status { 77 | margin-top: 10px; 78 | padding: 5px; 79 | font-size: 14px; 80 | } 81 | -------------------------------------------------------------------------------- /server/static/app.js: -------------------------------------------------------------------------------- 1 | // Global client instance 2 | const client = new NeovimClient(); 3 | globalThis.client = client; 4 | 5 | // Setup keyboard handlers 6 | client.setupKeyboardHandlers(); 7 | 8 | // Window load handler 9 | globalThis.addEventListener("load", () => { 10 | client.connect(); 11 | 12 | const serverAddress = getUrlParameter("server"); 13 | if (serverAddress && isValidServerAddress(serverAddress)) { 14 | const connectionForm = document.getElementById("connection-form"); 15 | if (connectionForm) { 16 | connectionForm.style.opacity = "0.5"; 17 | } 18 | 19 | const addressInput = document.getElementById("nvim-address"); 20 | if (addressInput) { 21 | addressInput.value = decodeURIComponent(serverAddress); 22 | } 23 | 24 | setTimeout(() => { 25 | client.updateStatus("Auto-connecting to " + serverAddress + "..."); 26 | client.updateTitle(serverAddress + " (connecting...)"); 27 | client.updateFavicon("default"); 28 | client.connectToNeovim(decodeURIComponent(serverAddress)); 29 | }, 500); 30 | } 31 | }); 32 | 33 | // Terminal event handlers 34 | const terminal = document.getElementById("terminal"); 35 | 36 | terminal.addEventListener("keyup", (_event) => { 37 | if (!client.connected) return; 38 | }); 39 | 40 | terminal.addEventListener("paste", (event) => { 41 | if (!client.connected) return; 42 | 43 | event.preventDefault(); 44 | const text = event.clipboardData.getData("text"); 45 | client.sendInput(text); 46 | }); 47 | 48 | // Global connection function 49 | globalThis.connectToNeovim = function () { 50 | const addressInput = document.getElementById("nvim-address"); 51 | if (!addressInput) { 52 | console.error("Address input not found"); 53 | return; 54 | } 55 | 56 | const address = addressInput.value; 57 | 58 | if (address.trim()) { 59 | client.connectToNeovim(address); 60 | } else { 61 | client.updateStatus("Please enter a valid address"); 62 | } 63 | }; 64 | -------------------------------------------------------------------------------- /server/static/neovim.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | neovim-mark@2x 4 | Created with Sketch (http://www.bohemiancoding.com/sketch) 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /server/static/neovim-inactive.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | neovim-mark-greyscale@2x 4 | Created with Sketch (http://www.bohemiancoding.com/sketch) 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: Go 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | tags: ["v*"] 7 | pull_request: 8 | branches: ["main"] 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | goos: [linux, windows, darwin] 16 | goarch: [amd64, arm64] 17 | exclude: 18 | - goos: windows 19 | goarch: arm64 20 | steps: 21 | - uses: actions/checkout@v4 22 | with: 23 | fetch-depth: 0 24 | 25 | - name: Set up Go 26 | uses: actions/setup-go@v4 27 | with: 28 | go-version: "1.24.6" 29 | 30 | - name: Get version info 31 | id: version 32 | run: | 33 | if [[ $GITHUB_REF == refs/tags/* ]]; then 34 | VERSION=${GITHUB_REF#refs/tags/} 35 | else 36 | VERSION=$(git describe --tags --always --dirty 2>/dev/null || echo "dev") 37 | fi 38 | COMMIT=$(git rev-parse --short HEAD) 39 | DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ") 40 | echo "version=$VERSION" >> $GITHUB_OUTPUT 41 | 42 | - name: Build 43 | env: 44 | GOOS: ${{ matrix.goos }} 45 | GOARCH: ${{ matrix.goarch }} 46 | run: | 47 | mkdir -p dist 48 | LDFLAGS="-X main.version=${{ steps.version.outputs.version }}" 49 | if [ "$GOOS" = "windows" ]; then 50 | go build -ldflags "$LDFLAGS" -o dist/nvim-server-${{ matrix.goos }}-${{ matrix.goarch }}.exe 51 | else 52 | go build -ldflags "$LDFLAGS" -o dist/nvim-server-${{ matrix.goos }}-${{ matrix.goarch }} 53 | fi 54 | 55 | - name: Upload artifacts 56 | uses: actions/upload-artifact@v4 57 | with: 58 | name: nvim-server-${{ matrix.goos }}-${{ matrix.goarch }} 59 | path: dist/ 60 | test: 61 | runs-on: ubuntu-latest 62 | steps: 63 | - uses: actions/checkout@v4 64 | - name: Set up Go 65 | uses: actions/setup-go@v4 66 | with: 67 | go-version: "1.24.6" 68 | - name: Test 69 | run: go test -v ./... 70 | 71 | release: 72 | if: startsWith(github.ref, 'refs/tags/v') 73 | needs: [build, test] 74 | runs-on: ubuntu-latest 75 | permissions: 76 | contents: write 77 | steps: 78 | - uses: actions/checkout@v4 79 | - name: Download all artifacts 80 | uses: actions/download-artifact@v4 81 | with: 82 | path: artifacts 83 | - name: Create Release 84 | uses: ncipollo/release-action@v1 85 | with: 86 | generateReleaseNotes: true 87 | allowUpdates: true 88 | artifacts: "artifacts/*/nvim-server-*" 89 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Neovim in the Browser 2 | 3 | Screenshot from 2025-08-30 20-58-16 4 | 5 | `nvim-server` is a web frontend for [Neovim](https://neovim.io/) designed around 6 | allowing the user to run Neovim anywhere you have a browser. 7 | 8 | Note this project was vibe coded over a two day period, and I'm at the point in 9 | which I believe I have a minimal viable product. Next steps include addressing 10 | roadmap items and trying to refactor / understand parts of the code I had the 11 | AI generate. Right now I'd consider nvim-server an MVP based on my personal 12 | requirements. 13 | 14 | ## Features 15 | 16 | - One server can connect to multiple clients. 17 | - Full clipboard integration using a custom clipboard provider. 18 | - GPU acceleration. 19 | 20 | ## Usage 21 | 22 | First spawn the server: 23 | 24 | ``` 25 | $ ./nvim-server --address 0.0.0.0:9998 26 | ``` 27 | 28 | Then you can go to `http://localhost:9998` and enter the location of a remote 29 | neovim instance. You can optionally pass in the server address as a query 30 | string (e.g. `http://localhost:9998/?server=localhost:9000`) to automatically 31 | connect to your Neovim instance. 32 | 33 | Optionally, you can create a systemd unit to automate this entire process: 34 | 35 | ``` 36 | [Unit] 37 | Description=nvim-server 38 | 39 | [Service] 40 | ExecStart=nvim-server --address 0.0.0.0:9998 41 | Restart=always 42 | 43 | [Install] 44 | WantedBy=default.target 45 | ``` 46 | 47 | Note that if your nvim-server and nvim are on different LANs you may want to 48 | use a secure tunnel to encrypt your neovim RPC traffic. 49 | 50 | ## Clipboard Support 51 | 52 | Clipboard Support requires the user to have nvim-server running behind HTTPS 53 | as browsers block clipboard sharing for HTTP connections. 54 | 55 | ## Project Background 56 | 57 | Before starting this project I wrote a couple of blog posts about Neovim being 58 | a terminal emulator / multiplexer replacement. I may write future posts in the 59 | future elaborating on why Neovim in the browser was my eventual conclusion for 60 | creating an optimal development workflow. 61 | 62 | - [Remote Neovim for Dummies](https://kraust.github.io/posts/remote-neovim-for-dummies/) 63 | - [Neovim is a Multiplexer](https://kraust.github.io/posts/neovim-is-a-multiplexer/) 64 | 65 | ## Roadmap 66 | 67 | - Better font rendering support. 68 | 69 | ## Similar Projects 70 | 71 | - [Code Server](https://github.com/coder/code-server) - VSCode in the Browser 72 | - [Glowing Bear](https://github.com/glowing-bear/glowing-bear) - WeeChat in the Browser 73 | - [Neovide](https://github.com/neovide/neovide) - An amazing Neovim GUI that I've been using since 2020. 74 | 75 | -------------------------------------------------------------------------------- /server/server.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "embed" 5 | "fmt" 6 | "github.com/gorilla/websocket" 7 | "github.com/neovim/go-client/nvim" 8 | "io/fs" 9 | "log" 10 | "net/http" 11 | "strings" 12 | "sync" 13 | ) 14 | 15 | //go:embed static/* 16 | var staticFiles embed.FS 17 | 18 | type ClientSession struct { 19 | nvim *nvim.Nvim 20 | conn *websocket.Conn 21 | address string 22 | active bool 23 | uiAttached bool 24 | } 25 | 26 | type Server struct { 27 | upgrader websocket.Upgrader 28 | clients map[*websocket.Conn]*ClientSession 29 | mu sync.RWMutex 30 | } 31 | 32 | func Serve(address string) error { 33 | ctx := &Server{ 34 | upgrader: websocket.Upgrader{ 35 | CheckOrigin: func(r *http.Request) bool { return true }, 36 | }, 37 | clients: make(map[*websocket.Conn]*ClientSession), 38 | } 39 | 40 | staticFS, _ := fs.Sub(staticFiles, "static") 41 | http.Handle("/", http.FileServer(http.FS(staticFS))) 42 | 43 | http.HandleFunc("/ws", ctx.handleWebSocket) 44 | 45 | log.Printf("Server starting on %s", address) 46 | 47 | err := http.ListenAndServe(address, nil) 48 | if err != nil { 49 | return err 50 | } 51 | 52 | return nil 53 | } 54 | 55 | func (ctx *Server) listenToNeovimEvents(session *ClientSession) error { 56 | session.nvim.RegisterHandler("redraw", func(updates ...[]any) { 57 | if !session.active { 58 | return 59 | } 60 | for _, update := range updates { 61 | message := map[string]any{ 62 | "type": "redraw", 63 | "data": update, 64 | } 65 | ctx.sendToClient(session, message) 66 | } 67 | }) 68 | 69 | if err := session.nvim.Subscribe("redraw"); err != nil { 70 | return fmt.Errorf("failed to subscribe to redraw events: %w", err) 71 | } 72 | 73 | err := session.nvim.Serve() 74 | 75 | log.Printf("Neovim session closed for client") 76 | 77 | session.active = false 78 | session.uiAttached = false // Reset UI state 79 | ctx.sendToClient(session, map[string]any{ 80 | "type": "session_closed", 81 | "data": "Neovim session has been closed", 82 | }) 83 | 84 | return err 85 | } 86 | 87 | func (ctx *Server) sendToClient(session *ClientSession, message map[string]any) { 88 | if !session.active && message["type"] != "session_closed" { 89 | return 90 | } 91 | 92 | err := session.conn.WriteJSON(message) 93 | if err != nil { 94 | log.Printf("Write error to client: %v", err) 95 | session.active = false 96 | session.conn.Close() 97 | } 98 | } 99 | 100 | func (ctx *Server) handleClientMessage(session *ClientSession, msg map[string]any) { 101 | switch msg["type"] { 102 | case "connect": 103 | address, ok := msg["address"].(string) 104 | if !ok { 105 | ctx.sendToClient(session, map[string]any{ 106 | "type": "error", 107 | "data": "Invalid server address", 108 | }) 109 | return 110 | } 111 | 112 | if err := ctx.connectSessionToNeovim(session, address); err != nil { 113 | log.Printf("Failed to connect client to Neovim at %s: %v", address, err) 114 | ctx.sendToClient(session, map[string]any{ 115 | "type": "error", 116 | "data": fmt.Sprintf("Failed to connect to Neovim: %v", err), 117 | }) 118 | return 119 | } 120 | 121 | ctx.sendToClient(session, map[string]any{ 122 | "type": "connected", 123 | "data": "Successfully connected to Neovim", 124 | }) 125 | case "clipboard_content": 126 | if !session.active || session.nvim == nil { 127 | return 128 | } 129 | 130 | err := session.nvim.SetVar("nvim_server_clipboard", msg["data"]) 131 | if err != nil { 132 | log.Printf("Failed to set clipboard variable: %v", err) 133 | } 134 | default: 135 | if !session.active || session.nvim == nil { 136 | ctx.sendToClient(session, map[string]any{ 137 | "type": "error", 138 | "data": "Not connected to Neovim", 139 | }) 140 | return 141 | } 142 | 143 | ctx.handleNeovimCommand(session, msg) 144 | } 145 | } 146 | 147 | func (ctx *Server) handleNeovimCommand(session *ClientSession, msg map[string]any) { 148 | if !session.active || session.nvim == nil { 149 | ctx.sendToClient(session, map[string]any{ 150 | "type": "error", 151 | "data": "Neovim session is no longer active", 152 | }) 153 | return 154 | } 155 | 156 | switch msg["type"] { 157 | case "attach_ui": 158 | width := int(msg["width"].(float64)) 159 | height := int(msg["height"].(float64)) 160 | options := map[string]any{ 161 | "ext_linegrid": true, 162 | "ext_multigrid": false, 163 | "rgb": true, 164 | } 165 | if err := session.nvim.AttachUI(width, height, options); err != nil { 166 | log.Printf("Error attaching UI: %v", err) 167 | session.uiAttached = false 168 | if strings.Contains(err.Error(), "session closed") { 169 | session.active = false 170 | ctx.sendToClient(session, map[string]any{ 171 | "type": "session_closed", 172 | "data": "Neovim session has been closed", 173 | }) 174 | } 175 | } else { 176 | session.uiAttached = true 177 | } 178 | case "input": 179 | input := msg["data"].(string) 180 | if _, err := session.nvim.Input(input); err != nil { 181 | log.Printf("Error sending input: %v", err) 182 | if strings.Contains(err.Error(), "session closed") { 183 | session.active = false 184 | ctx.sendToClient(session, map[string]any{ 185 | "type": "session_closed", 186 | "data": "Neovim session has been closed", 187 | }) 188 | } 189 | } 190 | case "command": 191 | cmd := msg["data"].(string) 192 | 193 | if strings.Contains(cmd, "nvim_ui_attach") { 194 | if err := session.nvim.AttachUI(80, 24, map[string]any{ 195 | "ext_linegrid": true, 196 | "ext_multigrid": false, 197 | "rgb": true, 198 | }); err != nil { 199 | log.Printf("Error attaching UI: %v", err) 200 | } 201 | } else if after, ok := strings.CutPrefix(cmd, "lua "); ok { 202 | luaCode := after 203 | if err := session.nvim.ExecLua(luaCode, nil); err != nil { 204 | log.Printf("Error executing Lua: %v", err) 205 | } 206 | } else { 207 | if err := session.nvim.Command(cmd); err != nil { 208 | log.Printf("Error executing command: %v", err) 209 | } 210 | } 211 | case "resize": 212 | if !session.uiAttached { 213 | return 214 | } 215 | 216 | width := int(msg["width"].(float64)) 217 | height := int(msg["height"].(float64)) 218 | if err := session.nvim.TryResizeUI(width, height); err != nil { 219 | log.Printf("Error resizing UI: %v", err) 220 | if strings.Contains(err.Error(), "UI not attached") { 221 | session.uiAttached = false 222 | } else if strings.Contains(err.Error(), "session closed") { 223 | session.active = false 224 | session.uiAttached = false 225 | ctx.sendToClient(session, map[string]any{ 226 | "type": "session_closed", 227 | "data": "Neovim session has been closed", 228 | }) 229 | } 230 | } 231 | case "mouse": 232 | action := msg["action"].(string) 233 | button := int(msg["button"].(float64)) 234 | row := int(msg["row"].(float64)) 235 | col := int(msg["col"].(float64)) 236 | 237 | var input string 238 | switch button { 239 | case 0: 240 | switch action { 241 | case "press": 242 | input = fmt.Sprintf("<%d,%d>", col, row) 243 | case "drag": 244 | input = fmt.Sprintf("<%d,%d>", col, row) 245 | default: 246 | input = fmt.Sprintf("<%d,%d>", col, row) 247 | } 248 | case 2: 249 | switch action { 250 | case "press": 251 | input = fmt.Sprintf("<%d,%d>", col, row) 252 | case "drag": 253 | input = fmt.Sprintf("<%d,%d>", col, row) 254 | default: 255 | input = fmt.Sprintf("<%d,%d>", col, row) 256 | } 257 | } 258 | 259 | if input != "" { 260 | if _, err := session.nvim.Input(input); err != nil { 261 | log.Printf("Error sending mouse input: %v", err) 262 | } 263 | } 264 | case "scroll": 265 | direction := msg["direction"].(string) 266 | row := int(msg["row"].(float64)) 267 | col := int(msg["col"].(float64)) 268 | 269 | var input string 270 | if direction == "up" { 271 | input = fmt.Sprintf("<%d,%d>", col, row) 272 | } else { 273 | input = fmt.Sprintf("<%d,%d>", col, row) 274 | } 275 | 276 | if _, err := session.nvim.Input(input); err != nil { 277 | log.Printf("Error sending scroll input: %v", err) 278 | } 279 | 280 | } 281 | 282 | } 283 | 284 | func (ctx *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) { 285 | conn, err := ctx.upgrader.Upgrade(w, r, nil) 286 | if err != nil { 287 | log.Printf("WebSocket upgrade error: %v", err) 288 | return 289 | } 290 | defer conn.Close() 291 | 292 | session := &ClientSession{ 293 | conn: conn, 294 | active: false, 295 | } 296 | 297 | ctx.mu.Lock() 298 | ctx.clients[conn] = session 299 | ctx.mu.Unlock() 300 | 301 | defer func() { 302 | ctx.mu.Lock() 303 | if session.nvim != nil { 304 | session.nvim.Close() 305 | } 306 | delete(ctx.clients, conn) 307 | ctx.mu.Unlock() 308 | }() 309 | 310 | conn.WriteJSON(map[string]any{ 311 | "type": "ready", 312 | "data": "WebSocket connected. Please provide Neovim server addresctx.", 313 | }) 314 | 315 | for { 316 | var msg map[string]any 317 | err := conn.ReadJSON(&msg) 318 | if err != nil { 319 | log.Printf("Read error: %v", err) 320 | break 321 | } 322 | 323 | ctx.handleClientMessage(session, msg) 324 | } 325 | } 326 | 327 | func (ctx *Server) connectSessionToNeovim(session *ClientSession, address string) error { 328 | if session.nvim != nil { 329 | session.nvim.Close() 330 | session.nvim = nil 331 | } 332 | 333 | client, err := nvim.Dial(address) 334 | if err != nil { 335 | return fmt.Errorf("failed to dial %s: %w", address, err) 336 | } 337 | 338 | session.nvim = client 339 | session.address = address 340 | session.active = true 341 | log.Printf("Successfully connected client to neovim at %s", address) 342 | 343 | if err := ctx.setupClipboard(session); err != nil { 344 | log.Printf("Failed to setup clipboard: %v", err) 345 | } 346 | 347 | go func() { 348 | if err := ctx.listenToNeovimEvents(session); err != nil { 349 | log.Printf("Error in Neovim event listener: %v", err) 350 | } 351 | }() 352 | 353 | return nil 354 | } 355 | 356 | func (ctx *Server) setupClipboard(session *ClientSession) error { 357 | channelID := session.nvim.ChannelID() 358 | 359 | clipboardConfig := fmt.Sprintf(` 360 | vim.g.clipboard = { 361 | name = 'nvim-server', 362 | copy = { 363 | ['+'] = function(lines, regtype) 364 | local content = table.concat(lines, '\n') 365 | vim.rpcnotify(%d, 'clipboard_copy', content) 366 | return 0 367 | end, 368 | ['*'] = function(lines, regtype) 369 | local content = table.concat(lines, '\n') 370 | vim.rpcnotify(%d, 'clipboard_copy', content) 371 | return 0 372 | end, 373 | }, 374 | paste = { 375 | ['+'] = function() 376 | vim.g.nvim_server_clipboard = nil 377 | vim.rpcnotify(%d, 'clipboard_paste') 378 | 379 | local timeout = 300 380 | while timeout > 0 and vim.g.nvim_server_clipboard == nil do 381 | vim.wait(10) 382 | timeout = timeout - 1 383 | end 384 | 385 | local content = vim.g.nvim_server_clipboard 386 | if content == nil or content == '' then 387 | print('Clipboard paste timeout or empty') 388 | return {''} 389 | end 390 | 391 | return vim.split(content, '\n', { plain = true }) 392 | end, 393 | ['*'] = function() 394 | vim.g.nvim_server_clipboard = nil 395 | vim.rpcnotify(%d, 'clipboard_paste') 396 | 397 | local timeout = 300 398 | while timeout > 0 and vim.g.nvim_server_clipboard == nil do 399 | vim.wait(10) 400 | timeout = timeout - 1 401 | end 402 | 403 | local content = vim.g.nvim_server_clipboard 404 | if content == nil or content == '' then 405 | return {''} 406 | end 407 | 408 | return vim.split(content, '\n', { plain = true }) 409 | end, 410 | } 411 | } 412 | return true 413 | `, channelID, channelID, channelID, channelID) 414 | 415 | var result bool 416 | if err := session.nvim.ExecLua(clipboardConfig, &result); err != nil { 417 | return err 418 | } 419 | 420 | reloadConfig := ` 421 | vim.g.loaded_clipboard_provider = nil 422 | vim.cmd('runtime autoload/provider/clipboard.vim') 423 | vim.opt.clipboard = 'unnamedplus' 424 | return true 425 | ` 426 | 427 | if err := session.nvim.ExecLua(reloadConfig, &result); err != nil { 428 | return err 429 | } 430 | 431 | // Register message handlers 432 | session.nvim.RegisterHandler("clipboard_copy", func(content string) { 433 | ctx.sendToClient(session, map[string]any{ 434 | "type": "clipboard_set", 435 | "data": content, 436 | }) 437 | }) 438 | 439 | session.nvim.RegisterHandler("clipboard_paste", func() { 440 | ctx.sendToClient(session, map[string]any{ 441 | "type": "clipboard_get", 442 | }) 443 | }) 444 | 445 | return nil 446 | } 447 | -------------------------------------------------------------------------------- /server/static/client.js: -------------------------------------------------------------------------------- 1 | class NeovimClient { 2 | constructor() { 3 | this.ws = null; 4 | this.connected = false; 5 | this.renderer = null; 6 | this.clipboardEnabled = navigator.clipboard && 7 | globalThis.isSecureContext; 8 | this.lastClipboardContent = ""; 9 | this.requestClipboardPermission(); 10 | } 11 | 12 | initRenderer() { 13 | const canvas = document.getElementById("terminal"); 14 | if (canvas) { 15 | this.renderer = new NeovimRenderer(canvas); 16 | } 17 | } 18 | 19 | showConnectionForm() { 20 | const connectionForm = document.getElementById("connection-form"); 21 | const terminal = document.getElementById("terminal"); 22 | 23 | if (connectionForm) { 24 | connectionForm.style.display = "block"; 25 | connectionForm.style.opacity = "1"; 26 | } 27 | 28 | if (terminal) { 29 | terminal.classList.remove("connected"); 30 | terminal.style.display = "none"; 31 | } 32 | 33 | if (this.renderer) { 34 | this.renderer = null; 35 | } 36 | 37 | const addressInput = document.getElementById("nvim-address"); 38 | if (addressInput) { 39 | addressInput.focus(); 40 | } 41 | } 42 | 43 | updateTitle(serverAddress = null, status = null) { 44 | let title = "Neovim Server"; 45 | 46 | if (serverAddress) { 47 | title += ` - ${serverAddress}`; 48 | } 49 | 50 | if (status) { 51 | title += ` (${status})`; 52 | } 53 | 54 | document.title = title; 55 | } 56 | 57 | updateFavicon(status = "default") { 58 | let link = document.querySelector("link[rel*='icon']"); 59 | if (!link) { 60 | link = document.createElement("link"); 61 | link.type = "image/svg+xml"; 62 | link.rel = "shortcut icon"; 63 | document.head.appendChild(link); 64 | } 65 | 66 | switch (status) { 67 | case "error": { 68 | link.href = "neovim-inactive.svg"; 69 | break; 70 | } 71 | case "connecting": { 72 | link.href = "neovim-inactive.svg"; 73 | break; 74 | } 75 | default: { 76 | link.href = "neovim.svg"; 77 | break; 78 | } 79 | } 80 | } 81 | 82 | handleMessage(msg) { 83 | switch (msg.type) { 84 | case "ready": { 85 | this.updateStatus("Ready to connect to Neovim"); 86 | this.updateTitle(); 87 | this.updateFavicon("default"); 88 | break; 89 | } 90 | case "connected": { 91 | this.connected = true; 92 | this.updateStatus( 93 | "Connected to Neovim successfully! Initializing UI...", 94 | ); 95 | 96 | const addressInput = document.getElementById("nvim-address"); 97 | if (addressInput && addressInput.value) { 98 | this.updateTitle(addressInput.value); 99 | this.updateFavicon("connected"); 100 | } 101 | 102 | this.hideConnectionForm(); 103 | this.initRenderer(); 104 | 105 | setTimeout(() => { 106 | this.resizeTerminalToFullViewport(); 107 | this.attachUI(); 108 | }, 100); 109 | 110 | this.sendCommand("set mouse=a"); 111 | 112 | document.getElementById("terminal").focus(); 113 | break; 114 | } 115 | case "session_closed": { 116 | this.connected = false; 117 | this.updateStatus("Neovim session closed - " + msg.data); 118 | this.updateTitle(); 119 | this.updateFavicon("error"); 120 | this.showConnectionForm(); 121 | break; 122 | } 123 | case "error": { 124 | console.error("Error:", msg.data); 125 | this.updateStatus("Error: " + msg.data); 126 | this.updateTitle(); 127 | this.updateFavicon("error"); 128 | break; 129 | } 130 | case "redraw": { 131 | if (this.renderer && Array.isArray(msg.data)) { 132 | this.renderer.handleRedrawEvent(msg.data); 133 | } 134 | break; 135 | } 136 | case "clipboard_set": { 137 | if (this.clipboardEnabled && msg.data) { 138 | this.syncToSystemClipboard(msg.data); 139 | } 140 | break; 141 | } 142 | case "clipboard_get": { 143 | this.sendClipboardToNeovim(); 144 | break; 145 | } 146 | default: { 147 | console.log("Unknown message type:", msg.type); 148 | } 149 | } 150 | } 151 | 152 | autoConnect(address) { 153 | if (this.ws && this.ws.readyState === WebSocket.OPEN) { 154 | this.connectToNeovim(address); 155 | } else { 156 | const checkConnection = setInterval(() => { 157 | if (this.ws && this.ws.readyState === WebSocket.OPEN) { 158 | clearInterval(checkConnection); 159 | this.connectToNeovim(address); 160 | } 161 | }, 100); 162 | 163 | setTimeout(() => { 164 | clearInterval(checkConnection); 165 | this.updateStatus( 166 | "Failed to establish WebSocket connection for auto-connect", 167 | ); 168 | }, 5000); 169 | } 170 | } 171 | 172 | attachUI() { 173 | if (this.connected && this.ws && this.renderer) { 174 | this.ws.send( 175 | JSON.stringify({ 176 | type: "attach_ui", 177 | width: this.renderer.cols, 178 | height: this.renderer.rows, 179 | }), 180 | ); 181 | this.renderer.startCursorBlink(); 182 | this.updateStatus("UI attachment requested..."); 183 | } 184 | } 185 | 186 | hideConnectionForm() { 187 | const connectionForm = document.getElementById("connection-form"); 188 | const terminal = document.getElementById("terminal"); 189 | 190 | if (connectionForm) { 191 | connectionForm.style.display = "none"; 192 | } 193 | 194 | if (terminal) { 195 | terminal.classList.add("connected"); 196 | terminal.style.display = "block"; 197 | } 198 | 199 | this.resizeTerminalToFullViewport(); 200 | } 201 | 202 | resizeTerminalToFullViewport() { 203 | const canvas = document.getElementById("terminal"); 204 | if (!canvas || !this.renderer) return; 205 | 206 | const containerWidth = globalThis.innerWidth; 207 | const containerHeight = globalThis.innerHeight; 208 | 209 | const newDimensions = this.renderer.resize( 210 | containerWidth, 211 | containerHeight, 212 | ); 213 | this.sendResize(newDimensions.width, newDimensions.height); 214 | } 215 | 216 | connect() { 217 | const protocol = globalThis.location.protocol === "https:" 218 | ? "wss:" 219 | : "ws:"; 220 | const wsUrl = `${protocol}//${globalThis.location.host}/ws`; 221 | this.ws = new WebSocket(wsUrl); 222 | 223 | this.ws.onopen = () => { 224 | this.updateStatus("WebSocket connected"); 225 | this.setupResizeHandler(); 226 | this.setupMouseHandlers(); 227 | }; 228 | 229 | this.ws.onmessage = (event) => { 230 | const msg = JSON.parse(event.data); 231 | this.handleMessage(msg); 232 | }; 233 | 234 | this.ws.onclose = () => { 235 | this.connected = false; 236 | this.updateStatus("WebSocket disconnected - Connection lost"); 237 | this.updateTitle(); 238 | this.updateFavicon("error"); 239 | this.showConnectionForm(); 240 | }; 241 | 242 | this.ws.onerror = (error) => { 243 | console.error("WebSocket error:", error); 244 | this.updateStatus("WebSocket error"); 245 | this.updateFavicon("error"); 246 | this.showConnectionForm(); 247 | }; 248 | } 249 | 250 | setupMouseHandlers() { 251 | const terminal = document.getElementById("terminal"); 252 | if (!terminal) return; 253 | 254 | let dragThrottle = null; 255 | 256 | terminal.addEventListener("click", () => { 257 | terminal.focus(); 258 | }); 259 | 260 | terminal.addEventListener("contextmenu", (event) => { 261 | event.preventDefault(); 262 | event.stopPropagation(); 263 | }); 264 | 265 | terminal.addEventListener("focus", () => { 266 | if (this.connected && this.clipboardEnabled) { 267 | this.sendClipboardToNeovim(); 268 | } 269 | }); 270 | 271 | // Also sync on window focus 272 | globalThis.addEventListener("focus", () => { 273 | if (this.connected && this.clipboardEnabled) { 274 | this.sendClipboardToNeovim(); 275 | } 276 | }); 277 | 278 | terminal.addEventListener("mousedown", (event) => { 279 | if (!this.connected || !this.renderer) return; 280 | terminal.focus(); 281 | 282 | const coords = this.getMouseCoords(event); 283 | this.sendMouseEvent("press", coords.row, coords.col, event.button); 284 | event.preventDefault(); 285 | }); 286 | 287 | terminal.addEventListener("mouseup", (event) => { 288 | if (!this.connected || !this.renderer) return; 289 | 290 | const coords = this.getMouseCoords(event); 291 | this.sendMouseEvent( 292 | "release", 293 | coords.row, 294 | coords.col, 295 | event.button, 296 | ); 297 | event.preventDefault(); 298 | }); 299 | 300 | terminal.addEventListener("wheel", (event) => { 301 | if (!this.connected || !this.renderer) return; 302 | 303 | const coords = this.getMouseCoords(event); 304 | const direction = event.deltaY > 0 ? "down" : "up"; 305 | this.sendScrollEvent(direction, coords.row, coords.col); 306 | event.preventDefault(); 307 | }); 308 | 309 | terminal.addEventListener("mousemove", (event) => { 310 | if (!this.connected || !this.renderer) return; 311 | 312 | if (event.buttons > 0) { 313 | if (!dragThrottle) { 314 | dragThrottle = setTimeout(() => { 315 | const coords = this.getMouseCoords(event); 316 | this.sendMouseEvent( 317 | "drag", 318 | coords.row, 319 | coords.col, 320 | event.button, 321 | ); 322 | dragThrottle = null; 323 | }, 16); 324 | } 325 | event.preventDefault(); 326 | } 327 | }); 328 | } 329 | 330 | getMouseCoords(event) { 331 | const rect = event.target.getBoundingClientRect(); 332 | const x = event.clientX - rect.left; 333 | const y = event.clientY - rect.top; 334 | 335 | const col = Math.floor(x / this.renderer.cellWidth); 336 | const row = Math.floor(y / this.renderer.cellHeight); 337 | 338 | return { 339 | row: Math.max(0, Math.min(row, this.renderer.rows - 1)), 340 | col: Math.max(0, Math.min(col, this.renderer.cols - 1)), 341 | }; 342 | } 343 | 344 | sendMouseEvent(action, row, col, button) { 345 | if (!this.connected || !this.ws) return; 346 | 347 | this.ws.send( 348 | JSON.stringify({ 349 | type: "mouse", 350 | action: action, 351 | button: button, 352 | row: row, 353 | col: col, 354 | immediate: true, 355 | }), 356 | ); 357 | } 358 | 359 | sendScrollEvent(direction, row, col) { 360 | if (!this.connected || !this.ws) return; 361 | 362 | this.ws.send( 363 | JSON.stringify({ 364 | type: "scroll", 365 | direction: direction, 366 | row: row, 367 | col: col, 368 | immediate: true, 369 | }), 370 | ); 371 | } 372 | 373 | setupKeyboardHandlers() { 374 | document.addEventListener("DOMContentLoaded", () => { 375 | const terminal = document.getElementById("terminal"); 376 | if (!terminal) { 377 | console.error("Terminal element not found"); 378 | return; 379 | } 380 | 381 | terminal.addEventListener("keydown", (event) => { 382 | if (!this.connected) return; 383 | event.preventDefault(); 384 | const key = this.translateKey(event); 385 | if (key) { 386 | this.sendInput(key); 387 | } 388 | }); 389 | 390 | terminal.focus(); 391 | }); 392 | } 393 | 394 | translateKey(event) { 395 | const { key, code, ctrlKey, altKey, shiftKey, metaKey } = event; 396 | 397 | // for See 398 | // https://pkg.go.dev/github.com/neovim/go-client/nvim#Nvim.Input 399 | const specialKeys = { 400 | Enter: "", 401 | Escape: "", 402 | Backspace: "", 403 | Tab: "", 404 | Delete: "", 405 | Insert: "", 406 | Home: "", 407 | End: "", 408 | PageUp: "", 409 | PageDown: "", 410 | ArrowUp: "", 411 | ArrowDown: "", 412 | ArrowLeft: "", 413 | ArrowRight: "", 414 | " ": "", 415 | "<": "", 416 | }; 417 | 418 | for (let i = 1; i <= 12; i++) { 419 | specialKeys[`F${i}`] = ``; 420 | } 421 | 422 | let modifiers = ""; 423 | 424 | if (ctrlKey) { 425 | modifiers += "C-"; 426 | } 427 | 428 | if (altKey) { 429 | modifiers += "A-"; 430 | } 431 | 432 | if (metaKey) { 433 | modifiers += "D-"; 434 | } 435 | 436 | if (shiftKey && !this.isShiftableKey(key)) { 437 | modifiers += "S-"; 438 | } 439 | 440 | if (specialKeys[key]) { 441 | if (modifiers) { 442 | return `<${modifiers}${specialKeys[key].slice(1, -1)}>`; 443 | } 444 | return specialKeys[key]; 445 | } 446 | 447 | if (key.length === 1) { 448 | if (modifiers) { 449 | if (ctrlKey && !altKey && !metaKey) { 450 | return ``; 451 | } 452 | return `<${modifiers}${key}>`; 453 | } 454 | return key; 455 | } 456 | 457 | if (code.startsWith("Numpad")) { 458 | const numpadKeys = { 459 | Numpad0: "0", 460 | Numpad1: "1", 461 | Numpad2: "2", 462 | Numpad3: "3", 463 | Numpad4: "4", 464 | Numpad5: "5", 465 | Numpad6: "6", 466 | Numpad7: "7", 467 | Numpad8: "8", 468 | Numpad9: "9", 469 | NumpadDecimal: ".", 470 | NumpadAdd: "+", 471 | NumpadSubtract: "-", 472 | NumpadMultiply: "*", 473 | NumpadDivide: "/", 474 | NumpadEnter: "", 475 | }; 476 | 477 | if (numpadKeys[code]) { 478 | if (modifiers) { 479 | return `<${modifiers}${numpadKeys[code]}>`; 480 | } 481 | return numpadKeys[code]; 482 | } 483 | } 484 | 485 | console.log("Unhandled key:", { 486 | key, 487 | code, 488 | ctrlKey, 489 | altKey, 490 | shiftKey, 491 | metaKey, 492 | }); 493 | return null; 494 | } 495 | 496 | isShiftableKey(key) { 497 | const shiftableKeys = [ 498 | "!", 499 | "@", 500 | "#", 501 | "$", 502 | "%", 503 | "^", 504 | "&", 505 | "*", 506 | "(", 507 | ")", 508 | "_", 509 | "+", 510 | "{", 511 | "}", 512 | "|", 513 | ":", 514 | '"', 515 | "<", 516 | ">", 517 | "?", 518 | "~", 519 | "A", 520 | "B", 521 | "C", 522 | "D", 523 | "E", 524 | "F", 525 | "G", 526 | "H", 527 | "I", 528 | "J", 529 | "K", 530 | "L", 531 | "M", 532 | "N", 533 | "O", 534 | "P", 535 | "Q", 536 | "R", 537 | "S", 538 | "T", 539 | "U", 540 | "V", 541 | "W", 542 | "X", 543 | "Y", 544 | "Z", 545 | ]; 546 | return shiftableKeys.includes(key) || key.length === 1; 547 | } 548 | 549 | setupResizeHandler() { 550 | let resizeTimeout; 551 | 552 | const handleResize = () => { 553 | if (!this.connected || !this.renderer) return; 554 | 555 | const canvas = document.getElementById("terminal"); 556 | if (!canvas) return; 557 | 558 | const connectionForm = document.getElementById("connection-form"); 559 | const isFormVisible = connectionForm && 560 | connectionForm.style.display !== "none"; 561 | 562 | let containerWidth, containerHeight; 563 | 564 | if (isFormVisible) { 565 | const formHeight = connectionForm.offsetHeight + 40; 566 | containerWidth = globalThis.innerWidth; 567 | containerHeight = globalThis.innerHeight - formHeight - 40; 568 | } else { 569 | containerWidth = globalThis.innerWidth; 570 | containerHeight = globalThis.innerHeight - 40; 571 | } 572 | 573 | canvas.style.width = containerWidth + "px"; 574 | canvas.style.height = containerHeight + "px"; 575 | 576 | const newDimensions = this.renderer.resize( 577 | containerWidth, 578 | containerHeight, 579 | ); 580 | this.sendResize(newDimensions.width, newDimensions.height); 581 | }; 582 | 583 | globalThis.addEventListener("resize", () => { 584 | clearTimeout(resizeTimeout); 585 | resizeTimeout = setTimeout(handleResize, 100); 586 | }); 587 | 588 | setTimeout(handleResize, 100); 589 | } 590 | 591 | sendResize(width, height) { 592 | if ( 593 | this.connected && this.ws && this.ws.readyState === WebSocket.OPEN 594 | ) { 595 | this.ws.send( 596 | JSON.stringify({ 597 | type: "resize", 598 | width: width, 599 | height: height, 600 | immediate: true, 601 | }), 602 | ); 603 | } 604 | } 605 | 606 | connectToNeovim(address) { 607 | if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { 608 | this.updateStatus("Reconnecting to server..."); 609 | this.connect(); 610 | 611 | const checkConnection = setInterval(() => { 612 | if (this.ws && this.ws.readyState === WebSocket.OPEN) { 613 | clearInterval(checkConnection); 614 | this.ws.send( 615 | JSON.stringify({ 616 | type: "connect", 617 | address: address, 618 | }), 619 | ); 620 | } 621 | }, 100); 622 | 623 | setTimeout(() => { 624 | clearInterval(checkConnection); 625 | if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { 626 | this.updateStatus("Failed to reconnect to server"); 627 | } 628 | }, 5000); 629 | } else { 630 | this.ws.send( 631 | JSON.stringify({ 632 | type: "connect", 633 | address: address, 634 | }), 635 | ); 636 | } 637 | } 638 | 639 | updateStatus(message) { 640 | const statusDiv = document.getElementById("status"); 641 | if (statusDiv) { 642 | statusDiv.textContent = message; 643 | } 644 | } 645 | 646 | sendInput(input) { 647 | if ( 648 | this.connected && this.ws && this.ws.readyState === WebSocket.OPEN 649 | ) { 650 | this.ws.send( 651 | JSON.stringify({ 652 | type: "input", 653 | data: input, 654 | immediate: true, 655 | }), 656 | ); 657 | } 658 | } 659 | 660 | sendCommand(command) { 661 | if (this.connected && this.ws) { 662 | this.ws.send( 663 | JSON.stringify({ 664 | type: "command", 665 | data: command, 666 | immediate: true, 667 | }), 668 | ); 669 | } 670 | } 671 | 672 | async syncToSystemClipboard(text) { 673 | if (!this.clipboardEnabled || text === this.lastClipboardContent) { 674 | return; 675 | } 676 | try { 677 | await navigator.clipboard.writeText(text); 678 | this.lastClipboardContent = text; 679 | } catch (err) { 680 | console.error("Failed to sync clipboard:", err); 681 | } 682 | } 683 | 684 | async sendClipboardToNeovim() { 685 | if (!this.clipboardEnabled) { 686 | return; 687 | } 688 | try { 689 | const text = await navigator.clipboard.readText(); 690 | if (this.connected && this.ws) { 691 | this.ws.send( 692 | JSON.stringify({ 693 | type: "clipboard_content", 694 | data: text, 695 | }), 696 | ); 697 | } 698 | } catch (err) { 699 | console.error("Failed to read clipboard:", err); 700 | // Send empty content on error 701 | if (this.connected && this.ws) { 702 | this.ws.send( 703 | JSON.stringify({ 704 | type: "clipboard_content", 705 | data: "", 706 | }), 707 | ); 708 | } 709 | } 710 | } 711 | 712 | async requestClipboardPermission() { 713 | if (!navigator.clipboard || !globalThis.isSecureContext) { 714 | console.warn("Clipboard API not available (requires HTTPS)"); 715 | return false; 716 | } 717 | try { 718 | const permission = await navigator.permissions.query({ 719 | name: "clipboard-read", 720 | }); 721 | if (permission.state === "granted") { 722 | this.clipboardEnabled = true; 723 | return true; 724 | } else if (permission.state === "prompt") { 725 | await navigator.clipboard.readText(); 726 | this.clipboardEnabled = true; 727 | return true; 728 | } 729 | } catch (_err) { 730 | this.clipboardEnabled = false; 731 | } 732 | return false; 733 | } 734 | } 735 | 736 | if (typeof module !== "undefined" && module.exports) { 737 | module.exports = NeovimClient; 738 | } 739 | -------------------------------------------------------------------------------- /server/static/renderer.js: -------------------------------------------------------------------------------- 1 | class NeovimRenderer { 2 | constructor(canvas) { 3 | this.canvas = canvas; 4 | this.ctx = canvas.getContext("2d"); 5 | this.fontFamily = "monospace"; 6 | this.fontSize = 14; 7 | this.cellWidth = 12; 8 | this.cellHeight = 20; 9 | this.rows = 24; 10 | this.cols = 80; 11 | this.grid = []; 12 | this.cursor = { row: 0, col: 0 }; 13 | this.cursorMode = "normal"; 14 | this.cursorVisible = true; 15 | this.renderPending = false; 16 | this.lastRenderTime = 0; 17 | this.targetFPS = 60; 18 | this.frameInterval = 1000 / this.targetFPS; 19 | this.colors = { 20 | fg: "#ffffff", 21 | bg: "#000000", 22 | }; 23 | this.highlights = new Map(); 24 | 25 | this.initGrid(); 26 | this.setupCanvas(); 27 | this.updateFont(); 28 | this.startCursorBlink(); 29 | } 30 | 31 | updateFont() { 32 | this.ctx.font = `${this.fontSize}px ${this.fontFamily}`; 33 | this.ctx.textBaseline = "top"; 34 | 35 | this.cellWidth = Math.round(this.fontSize * 0.6); 36 | this.cellHeight = Math.ceil(this.fontSize * 1.2); 37 | 38 | const currentWidth = this.canvas.width || this.canvas.offsetWidth; 39 | const currentHeight = this.canvas.height || this.canvas.offsetHeight; 40 | 41 | this.cols = Math.floor(currentWidth / this.cellWidth); 42 | this.rows = Math.floor(currentHeight / this.cellHeight); 43 | 44 | this.initGrid(); 45 | this.redraw(); 46 | 47 | if (globalThis.client && globalThis.client.connected) { 48 | globalThis.client.sendResize(this.cols, this.rows); 49 | } 50 | } 51 | 52 | setFont(fontString) { 53 | const fontMatch = fontString.match(/^([^:]+)(?::h(\d+))?$/) || 54 | fontString.match(/^([^\d]+)\s+(\d+)$/); 55 | 56 | if (fontMatch) { 57 | const fontFamily = fontMatch[1].trim(); 58 | const newFontSize = parseInt(fontMatch[2]) || 12; 59 | 60 | if (newFontSize !== this.fontSize) { 61 | this.fontSize = newFontSize; 62 | } 63 | 64 | this.fontFamily = `${fontFamily},monospace`; 65 | } 66 | 67 | this.updateFont(); 68 | 69 | // Notify client to send resize to server 70 | if (globalThis.client && globalThis.client.connected) { 71 | globalThis.client.sendResize(this.cols, this.rows); 72 | } 73 | } 74 | 75 | initGrid() { 76 | this.grid = Array(this.rows) 77 | .fill() 78 | .map(() => 79 | Array(this.cols) 80 | .fill() 81 | .map(() => ({ 82 | char: " ", 83 | fg: this.colors.fg, 84 | bg: this.colors.bg, 85 | })) 86 | ); 87 | } 88 | 89 | setupCanvas() { 90 | const dpr = globalThis.devicePixelRatio || 1; 91 | 92 | this.canvas.width = this.cols * this.cellWidth * dpr; 93 | this.canvas.height = this.rows * this.cellHeight * dpr; 94 | 95 | this.canvas.style.width = this.cols * this.cellWidth + "px"; 96 | this.canvas.style.height = this.rows * this.cellHeight + "px"; 97 | 98 | this.ctx.scale(dpr, dpr); 99 | this.ctx.font = `${this.fontSize}px ${this.fontFamily}`; 100 | this.ctx.textBaseline = "top"; 101 | 102 | this.ctx.imageSmoothingEnabled = false; 103 | this.ctx.textRenderingOptimization = "optimizeQuality"; 104 | this.ctx.fillStyle = this.colors.fg; 105 | 106 | this.clear(); 107 | } 108 | 109 | clear() { 110 | this.ctx.fillStyle = this.colors.bg; 111 | this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); 112 | } 113 | 114 | isWideChar(char) { 115 | if (!char || char.length === 0) return false; 116 | 117 | const code = char.codePointAt(0); 118 | if (!code) return false; 119 | 120 | // Use a more comprehensive check including Unicode East Asian Width property 121 | return ( 122 | // CJK Unified Ideographs 123 | (code >= 0x4e00 && code <= 0x9fff) || 124 | // CJK Extension A 125 | (code >= 0x3400 && code <= 0x4dbf) || 126 | // CJK Extension B 127 | (code >= 0x20000 && code <= 0x2a6df) || 128 | // Hangul Syllables 129 | (code >= 0xac00 && code <= 0xd7af) || 130 | // Hiragana 131 | (code >= 0x3040 && code <= 0x309f) || 132 | // Katakana 133 | (code >= 0x30a0 && code <= 0x30ff) || 134 | // Emoji ranges (comprehensive) 135 | (code >= 0x1f600 && code <= 0x1f64f) || // Emoticons 136 | (code >= 0x1f300 && code <= 0x1f5ff) || // Misc Symbols 137 | (code >= 0x1f680 && code <= 0x1f6ff) || // Transport 138 | (code >= 0x1f1e0 && code <= 0x1f1ff) || // Flags 139 | (code >= 0x2600 && code <= 0x26ff) || // Misc symbols 140 | (code >= 0x2700 && code <= 0x27bf) || // Dingbats 141 | (code >= 0x1f900 && code <= 0x1f9ff) || // Supplemental Symbols 142 | (code >= 0x1fa70 && code <= 0x1faff) || // Extended-A 143 | (code >= 0x2e80 && code <= 0x2eff) || // CJK Radicals 144 | (code >= 0x2f00 && code <= 0x2fdf) || // Kangxi Radicals 145 | (code >= 0x3000 && code <= 0x303f) || // CJK Symbols 146 | (code >= 0xff00 && code <= 0xffef) || // Halfwidth/Fullwidth Forms 147 | // Nerd Fonts icon ranges 148 | (code >= 0x23fb && code <= 0x23fe) || // IEC Power Symbols 149 | code === 0x2665 || // Octicons (heart) 150 | code === 0x26a1 || // Octicons (lightning) 151 | code === 0x2b58 || // IEC Power Symbols 152 | (code >= 0xe000 && code <= 0xe00a) || // Pomicons 153 | (code >= 0xe0a0 && code <= 0xe0a2) || // Powerline 154 | code === 0xe0a3 || // Powerline Extra 155 | (code >= 0xe0b0 && code <= 0xe0b3) || // Powerline 156 | (code >= 0xe0b4 && code <= 0xe0c8) || // Powerline Extra 157 | code === 0xe0ca || // Powerline Extra 158 | (code >= 0xe0cc && code <= 0xe0d7) || // Powerline Extra 159 | (code >= 0xe200 && code <= 0xe2a9) || // Font Awesome Extension 160 | (code >= 0xe300 && code <= 0xe3e3) || // Weather Icons 161 | (code >= 0xe5fa && code <= 0xe6b7) || // Seti-UI + Custom 162 | (code >= 0xe700 && code <= 0xe8ef) || // Devicons 163 | (code >= 0xea60 && code <= 0xec1e) || // Codicons 164 | (code >= 0xed00 && code <= 0xefce) || // Font Awesome (relocated) 165 | (code >= 0xf000 && code <= 0xf2ff) || // Font Awesome (relocated) 166 | (code >= 0xf300 && code <= 0xf381) || // Font Logos 167 | (code >= 0xf400 && code <= 0xf533) || // Octicons (relocated) 168 | (code >= 0xf500 && code <= 0xfd46) || // Material Design (old range) 169 | (code >= 0xf0001 && code <= 0xf1af0) // Material Design (new range) 170 | ); 171 | } 172 | 173 | drawCursor() { 174 | if (!this.cursorVisible) return; 175 | 176 | const x = this.cursor.col * this.cellWidth; 177 | const y = this.cursor.row * this.cellHeight; 178 | 179 | this.ctx.fillStyle = "#ffffff"; 180 | 181 | const modeStyle = this.modeStyles && this.currentModeIndex !== undefined 182 | ? this.modeStyles[this.currentModeIndex] 183 | : null; 184 | const cursorShape = modeStyle 185 | ? modeStyle.cursorShape 186 | : this.getCursorShapeForMode(this.cursorMode); 187 | 188 | switch (cursorShape) { 189 | case "block": { 190 | this.ctx.fillRect(x, y, this.cellWidth, this.cellHeight); 191 | const cell = this.grid[this.cursor.row] && 192 | this.grid[this.cursor.row][this.cursor.col]; 193 | if (cell && cell.char && cell.char !== " ") { 194 | this.ctx.fillStyle = cell.bg || this.colors.bg; 195 | this.ctx.fillText(cell.char, x, y + 2); 196 | } 197 | break; 198 | } 199 | case "vertical": { 200 | this.ctx.fillRect(x, y, 2, this.cellHeight); 201 | break; 202 | } 203 | case "horizontal": { 204 | const height = Math.max(2, Math.floor(this.cellHeight * 0.2)); 205 | this.ctx.fillRect( 206 | x, 207 | y + this.cellHeight - height, 208 | this.cellWidth, 209 | height, 210 | ); 211 | break; 212 | } 213 | default: { 214 | this.ctx.fillRect( 215 | x, 216 | y + this.cellHeight - 2, 217 | this.cellWidth, 218 | 2, 219 | ); 220 | } 221 | } 222 | } 223 | 224 | getCursorShapeForMode(mode) { 225 | switch (mode) { 226 | case "normal": 227 | case "visual": 228 | case "select": 229 | return "block"; 230 | case "insert": 231 | return "vertical"; 232 | case "replace": 233 | return "horizontal"; 234 | case "cmdline": 235 | case "cmdline_normal": 236 | return "horizontal"; 237 | default: 238 | return "block"; 239 | } 240 | } 241 | 242 | startCursorBlink() { 243 | if (this.cursorBlinkTimer) { 244 | clearInterval(this.cursorBlinkTimer); 245 | } 246 | 247 | const modeStyle = this.modeStyles && this.currentModeIndex !== undefined 248 | ? this.modeStyles[this.currentModeIndex] 249 | : null; 250 | 251 | if (modeStyle && (modeStyle.blinkon > 0 || modeStyle.blinkoff > 0)) { 252 | this.cursorBlinkTimer = setInterval(() => { 253 | this.cursorVisible = !this.cursorVisible; 254 | this.redraw(); 255 | }, modeStyle.blinkon || 500); 256 | } else { 257 | this.cursorVisible = true; 258 | } 259 | } 260 | 261 | handleRedrawEvent(event) { 262 | if (!Array.isArray(event) || event.length === 0) { 263 | console.log("Invalid event format:", event); 264 | return; 265 | } 266 | 267 | const eventType = event[0]; 268 | const eventData = event.slice(1); 269 | 270 | switch (eventType) { 271 | case "mode_change": 272 | this.handleModeChange(eventData); 273 | break; 274 | case "mode_info_set": 275 | this.handleModeInfoSet(eventData); 276 | break; 277 | case "option_set": 278 | this.handleOptionSet(eventData); 279 | break; 280 | case "grid_resize": 281 | this.handleGridResize(eventData); 282 | break; 283 | case "grid_line": 284 | this.handleGridLine(eventData); 285 | break; 286 | case "grid_cursor_goto": 287 | this.handleCursorGoto(eventData); 288 | break; 289 | case "grid_clear": 290 | this.handleGridClear(); 291 | break; 292 | case "default_colors_set": 293 | this.handleDefaultColors(eventData); 294 | break; 295 | case "flush": 296 | this.requestRedraw(); 297 | break; 298 | case "hl_attr_define": 299 | this.handleHlAttrDefine(eventData); 300 | break; 301 | case "win_viewport": 302 | this.handleWinViewport(eventData); 303 | break; 304 | case "grid_scroll": 305 | this.handleGridScroll(eventData); 306 | break; 307 | case "hl_group_set": 308 | break; 309 | case "chdir": 310 | break; 311 | default: 312 | console.log("Unhandled event type:", eventType, eventData); 313 | } 314 | } 315 | 316 | handleModeChange(eventData) { 317 | for (const modeData of eventData) { 318 | const [mode, modeIdx] = modeData; 319 | this.cursorMode = mode; 320 | this.currentModeIndex = modeIdx; 321 | this.startCursorBlink(); 322 | } 323 | } 324 | 325 | handleModeInfoSet(eventData) { 326 | for (const modeInfo of eventData) { 327 | const [cursorStyleEnabled, modeInfoList] = modeInfo; 328 | if (cursorStyleEnabled && Array.isArray(modeInfoList)) { 329 | this.modeStyles = {}; 330 | modeInfoList.forEach((info, idx) => { 331 | if (info && typeof info === "object") { 332 | this.modeStyles[idx] = { 333 | cursorShape: info.cursor_shape || "block", 334 | cellPercentage: info.cell_percentage || 100, 335 | blinkwait: info.blinkwait || 0, 336 | blinkon: info.blinkon || 0, 337 | blinkoff: info.blinkoff || 0, 338 | }; 339 | } 340 | }); 341 | this.startCursorBlink(); 342 | } 343 | } 344 | } 345 | 346 | handleGridScroll(eventData) { 347 | for (const scrollData of eventData) { 348 | if (!Array.isArray(scrollData) || scrollData.length < 7) continue; 349 | 350 | const [grid, top, bot, left, right, rows, _cols] = scrollData; 351 | 352 | if (grid !== 1) continue; 353 | 354 | if (rows > 0) { 355 | for (let row = top; row < bot - rows; row++) { 356 | for (let col = left; col < right; col++) { 357 | if (row + rows < this.rows && col < this.cols) { 358 | this.grid[row][col] = this.grid[row + rows][col]; 359 | } 360 | } 361 | } 362 | for (let row = bot - rows; row < bot; row++) { 363 | for (let col = left; col < right; col++) { 364 | if (row < this.rows && col < this.cols) { 365 | this.grid[row][col] = { 366 | char: " ", 367 | fg: this.colors.fg, 368 | bg: this.colors.bg, 369 | }; 370 | } 371 | } 372 | } 373 | } else if (rows < 0) { 374 | const absRows = Math.abs(rows); 375 | for (let row = bot - 1; row >= top + absRows; row--) { 376 | for (let col = left; col < right; col++) { 377 | if (row - absRows >= 0 && col < this.cols) { 378 | this.grid[row][col] = this.grid[row - absRows][col]; 379 | } 380 | } 381 | } 382 | for (let row = top; row < top + absRows; row++) { 383 | for (let col = left; col < right; col++) { 384 | if (row < this.rows && col < this.cols) { 385 | this.grid[row][col] = { 386 | char: " ", 387 | fg: this.colors.fg, 388 | bg: this.colors.bg, 389 | }; 390 | } 391 | } 392 | } 393 | } 394 | } 395 | } 396 | 397 | handleWinViewport(eventData) { 398 | for (const viewportData of eventData) { 399 | if (!Array.isArray(viewportData) || viewportData.length < 6) { 400 | continue; 401 | } 402 | 403 | const [grid, _win, topline, botline, curline, curcol] = 404 | viewportData; 405 | 406 | if (grid === 1) { 407 | console.log( 408 | `Viewport: lines ${topline}-${botline}, cursor at ${curline},${curcol}`, 409 | ); 410 | } 411 | } 412 | } 413 | 414 | handleOptionSet(eventData) { 415 | for (const optionData of eventData) { 416 | const [name, value] = optionData; 417 | switch (name) { 418 | case "guifont": 419 | if (value && typeof value === "string") { 420 | this.setFont(value); 421 | } 422 | break; 423 | case "linespace": 424 | if (typeof value === "number") { 425 | this.cellHeight = Math.ceil( 426 | this.fontSize * (1.2 + value / 10), 427 | ); 428 | this.setupCanvas(); 429 | } 430 | break; 431 | default: 432 | console.log(`Unhandled Option ${name} set to:`, value); 433 | break; 434 | } 435 | } 436 | } 437 | 438 | handleHlAttrDefine(eventData) { 439 | for (const hlData of eventData) { 440 | const [id, rgbAttrs, _ctermAttrs, _info] = hlData; 441 | 442 | const attrs = rgbAttrs || {}; 443 | 444 | this.highlights.set(id, { 445 | fg: attrs.foreground !== undefined 446 | ? this.rgbToHex(attrs.foreground) 447 | : this.colors.fg, 448 | bg: attrs.background !== undefined 449 | ? this.rgbToHex(attrs.background) 450 | : this.colors.bg, 451 | bold: attrs.bold || false, 452 | italic: attrs.italic || false, 453 | underline: attrs.underline || false, 454 | reverse: attrs.reverse || false, 455 | }); 456 | } 457 | } 458 | 459 | handleGridResize(args) { 460 | if (!args || args.length === 0) return; 461 | const [grid, width, height] = args[0] || args; 462 | if (grid === 1) { 463 | this.cols = width; 464 | this.rows = height; 465 | this.initGrid(); 466 | this.setupCanvas(); 467 | } 468 | } 469 | 470 | handleGridLine(eventData) { 471 | if (!eventData || eventData.length === 0) return; 472 | 473 | // Process each line immediately but store which rows changed 474 | const changedRows = new Set(); 475 | 476 | for (const lineData of eventData) { 477 | if (!Array.isArray(lineData) || lineData.length < 4) continue; 478 | 479 | const [grid, row, colStart, cells, _wrap] = lineData; 480 | 481 | if (grid !== 1 || row >= this.rows || row < 0) continue; 482 | 483 | // Process immediately without merging 484 | let col = colStart; 485 | let currentHlId = 0; 486 | 487 | if (cells && Array.isArray(cells)) { 488 | for (const cellData of cells) { 489 | if (col >= this.cols) break; 490 | 491 | let char, hlId, repeatCount; 492 | 493 | if (Array.isArray(cellData)) { 494 | char = cellData[0] || " "; 495 | if (cellData.length > 1 && cellData[1] !== undefined) { 496 | currentHlId = cellData[1]; 497 | } 498 | hlId = currentHlId; 499 | repeatCount = cellData.length > 2 ? cellData[2] : 1; 500 | } else { 501 | char = cellData || " "; 502 | hlId = currentHlId; 503 | repeatCount = 1; 504 | } 505 | 506 | for (let i = 0; i < repeatCount && col < this.cols; i++) { 507 | const highlight = this.highlights.get(hlId) || { 508 | fg: this.colors.fg, 509 | bg: this.colors.bg, 510 | }; 511 | 512 | this.grid[row][col] = { 513 | char: char, 514 | fg: highlight.fg, 515 | bg: highlight.bg, 516 | isWideChar: this.isWideChar(char), 517 | }; 518 | 519 | col++; 520 | } 521 | } 522 | } 523 | 524 | changedRows.add(row); 525 | } 526 | } 527 | 528 | handleCursorGoto(args) { 529 | if (!args || args.length === 0) return; 530 | const [grid, row, col] = args[0] || args; 531 | if (grid === 1) { 532 | const visualCol = this.logicalToVisualCol(row, col); 533 | this.cursor = { row, col: visualCol }; 534 | this.redraw(); 535 | } 536 | } 537 | 538 | logicalToVisualCol(row, logicalCol) { 539 | if (row >= this.rows || row < 0) return 0; 540 | if (logicalCol < 0) return 0; 541 | 542 | return Math.min(logicalCol, this.cols - 1); 543 | } 544 | 545 | handleDefaultColors(args) { 546 | if (!args || args.length === 0) return; 547 | const [fg, bg] = args[0] || args; 548 | this.colors.fg = this.rgbToHex(fg); 549 | this.colors.bg = this.rgbToHex(bg); 550 | this.clear(); 551 | } 552 | 553 | handleGridClear() { 554 | this.initGrid(); 555 | this.clear(); 556 | } 557 | 558 | rgbToHex(rgb) { 559 | if (rgb === undefined || rgb === null) { 560 | return null; 561 | } 562 | if (rgb === -1) { 563 | return null; 564 | } 565 | 566 | const value = rgb < 0 ? 0xffffff + rgb + 1 : rgb; 567 | return "#" + value.toString(16).padStart(6, "0"); 568 | } 569 | 570 | requestRedraw() { 571 | if (this.renderPending) return; 572 | 573 | const now = performance.now(); 574 | const timeSinceLastRender = now - this.lastRenderTime; 575 | 576 | if (timeSinceLastRender >= this.frameInterval) { 577 | // Render immediately if enough time has passed 578 | this.renderPending = true; 579 | requestAnimationFrame(() => { 580 | this.redraw(); 581 | this.lastRenderTime = performance.now(); 582 | this.renderPending = false; 583 | }); 584 | } else { 585 | // Schedule render for later 586 | this.renderPending = true; 587 | setTimeout(() => { 588 | requestAnimationFrame(() => { 589 | this.redraw(); 590 | this.lastRenderTime = performance.now(); 591 | this.renderPending = false; 592 | }); 593 | }, this.frameInterval - timeSinceLastRender); 594 | } 595 | } 596 | 597 | redraw() { 598 | this.clear(); 599 | 600 | const backgroundBatches = new Map(); 601 | const textBatches = new Map(); 602 | for (let row = 0; row < this.rows; row++) { 603 | for (let col = 0; col < this.cols; col++) { 604 | const cell = this.grid[row][col]; 605 | if (!cell) continue; 606 | 607 | if (cell.bg !== this.colors.bg) { 608 | if (!backgroundBatches.has(cell.bg)) { 609 | backgroundBatches.set(cell.bg, []); 610 | } 611 | backgroundBatches 612 | .get(cell.bg) 613 | .push({ 614 | x: col * this.cellWidth, 615 | y: row * this.cellHeight, 616 | }); 617 | 618 | if (cell.isWideChar && col + 1 < this.cols) { 619 | backgroundBatches.get(cell.bg).push({ 620 | x: (col + 1) * this.cellWidth, 621 | y: row * this.cellHeight, 622 | }); 623 | } 624 | } 625 | 626 | // Group text draws 627 | if (cell.char && cell.char !== " ") { 628 | if (!textBatches.has(cell.fg)) { 629 | textBatches.set(cell.fg, []); 630 | } 631 | textBatches.get(cell.fg).push({ 632 | char: cell.char, 633 | x: col * this.cellWidth, 634 | y: row * this.cellHeight, 635 | isWideChar: cell.isWideChar, 636 | }); 637 | } 638 | 639 | if (cell.isWideChar) col++; 640 | } 641 | } 642 | 643 | for (const [color, rects] of backgroundBatches) { 644 | this.ctx.fillStyle = color; 645 | for (const rect of rects) { 646 | this.ctx.fillRect( 647 | rect.x, 648 | rect.y, 649 | this.cellWidth, 650 | this.cellHeight, 651 | ); 652 | } 653 | } 654 | 655 | for (const [color, texts] of textBatches) { 656 | this.ctx.fillStyle = color; 657 | for (const text of texts) { 658 | if (text.isWideChar) { 659 | const oldAlign = this.ctx.textAlign; 660 | this.ctx.textAlign = "left"; 661 | this.ctx.fillText(text.char, text.x, text.y + 2); 662 | this.ctx.textAlign = oldAlign; 663 | } else { 664 | this.ctx.fillText(text.char, text.x, text.y + 2); 665 | } 666 | } 667 | } 668 | 669 | this.drawCursor(); 670 | } 671 | 672 | resize(width, height) { 673 | const dpr = globalThis.devicePixelRatio || 1; 674 | 675 | this.canvas.style.width = width + "px"; 676 | this.canvas.style.height = height + "px"; 677 | this.canvas.width = width * dpr; 678 | this.canvas.height = height * dpr; 679 | 680 | this.cols = Math.floor(width / this.cellWidth); 681 | this.rows = Math.floor(height / this.cellHeight); 682 | 683 | this.initGrid(); 684 | 685 | this.ctx.scale(dpr, dpr); 686 | this.ctx.font = `${this.fontSize}px ${this.fontFamily}`; 687 | this.ctx.textBaseline = "top"; 688 | this.ctx.imageSmoothingEnabled = false; 689 | this.ctx.textRenderingOptimization = "optimizeQuality"; 690 | 691 | this.redraw(); 692 | return { width: this.cols, height: this.rows }; 693 | } 694 | } 695 | 696 | if (typeof module !== "undefined" && module.exports) { 697 | module.exports = NeovimRenderer; 698 | } 699 | --------------------------------------------------------------------------------