├── LICENSE ├── README.md ├── frontend ├── babel.config.json ├── build.sh ├── jest.config.js ├── package.json ├── public │ └── index.html ├── snowpack.config.json ├── src │ ├── Kakoune.tsx │ ├── div.tsx │ └── index.tsx ├── tsconfig.json └── yarn.lock ├── kak_web_ui.py ├── requirements.in ├── requirements.txt └── setup.py /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Dan Rosén 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # kakoune web ui 2 | ​ 3 | [![IRC][IRC Badge]][IRC] 4 | 5 | Run the kakoune text editor in your browser! 6 | 7 | This project is looking for a use-case and most likely a maintainer. 8 | 9 | ## Installation and usage 10 | 11 | ``` 12 | pip install . --user 13 | ``` 14 | 15 | Run with 16 | 17 | ``` 18 | kak-web-ui 19 | ``` 20 | 21 | Now if you have a running kak session you can open it in your browser: 22 | 23 | ``` 24 | chromium --app=http://localhost:8234 25 | ``` 26 | 27 | ## License 28 | 29 | MIT 30 | 31 | [IRC]: https://webchat.freenode.net?channels=kakoune 32 | [IRC Badge]: https://img.shields.io/badge/IRC-%23kakoune-blue.svg 33 | -------------------------------------------------------------------------------- /frontend/babel.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@snowpack/app-scripts-react/babel.config.json", 3 | "plugins": [["emotion", {"sourceMap": true}]], 4 | "presets": [ 5 | [ 6 | "@babel/preset-react", 7 | { 8 | "development": true 9 | } 10 | ] 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /frontend/build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | rm -rf dist 3 | esbuild \ 4 | --bundle src/index.tsx \ 5 | --outdir=dist \ 6 | --target=es6 \ 7 | "--define:process.env.NODE_ENV='production'" \ 8 | --pure:console.log 9 | cd dist 10 | index_hash="main.$(md5sum index.js | head -c 10).js" 11 | mv index.js "$index_hash" 12 | sed 's,.*index.js.*,,' < ../public/index.html > index.html 13 | -------------------------------------------------------------------------------- /frontend/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | ...require('@snowpack/app-scripts-react/jest.config.js')(), 3 | setupFilesAfterEnv: [], 4 | clearMocks: true, 5 | testMatch: ['**/__tests__/**/*.[jt]s?(x)', '**/?(*)(spec|test).[tj]s?(x)'], 6 | forceCoverageMatch: ['**/*doctest*'], 7 | testEnvironment: 'jest-environment-jsdom', 8 | snapshotSerializers: ['jest-emotion'], 9 | moduleNameMapper: { 10 | '@app/(.*)': ['/src/$1'], 11 | }, 12 | } 13 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "kakoune-web-ui", 3 | "version": "0.1", 4 | "description": "Kakoune web ui frontend", 5 | "repository": "git@github.com:danr/kakoune-web-ui.git", 6 | "author": "Dan Rosén", 7 | "license": "MIT", 8 | "devDependencies": { 9 | "@babel/core": "^7.11.6", 10 | "@babel/preset-env": "^7.11.5", 11 | "@babel/preset-react": "^7.10.4", 12 | "@babel/preset-typescript": "^7.10.4", 13 | "@snowpack/app-scripts-react": "^1.12.3", 14 | "@snowpack/plugin-babel": "^2.1.3", 15 | "@snowpack/plugin-dotenv": "^2.0.3", 16 | "@snowpack/plugin-react-refresh": "^2.3.2", 17 | "@testing-library/react": "^11.0.4", 18 | "@types/jest": "^26.0.14", 19 | "@types/react": "^16.9.52", 20 | "@types/react-dom": "^16.9.8", 21 | "@types/react-test-renderer": "^16.9.3", 22 | "@types/snowpack-env": "^2.3.0", 23 | "babel-jest": "^26.5.2", 24 | "babel-plugin-emotion": "^10.0.33", 25 | "doctest-ts": "^0.5.0", 26 | "esbuild": "0.7.14", 27 | "jest": "^26.6.2", 28 | "jest-emotion": "^10.0.32", 29 | "path": "^0.12.7", 30 | "prettier": "^2.1.2", 31 | "react-test-renderer": "^16.13.1", 32 | "snowpack": "~2.13.3", 33 | "typescript": "^4.0.3" 34 | }, 35 | "scripts": { 36 | "start": "snowpack dev", 37 | "build": "./build.sh", 38 | "pretty": "yarn run prettier --list-different --write src test *json *js", 39 | "typecheck": "yarn run tsc", 40 | "doctests:gen": "find src test -name '*ts?' | xargs doctest-ts --jest", 41 | "doctests:watch": "find src test -name '*ts?' | entr -c sh -c 'yarn run doctests:gen && yarn run jest src --verbose --testEnvironment node'", 42 | "test": "yarn run doctests:gen && jest" 43 | }, 44 | "dependencies": { 45 | "@emotion/core": "^10.0.35", 46 | "emotion": "^10.0.27", 47 | "react": "^16.13.1", 48 | "react-dom": "^16.13.1", 49 | "react-is": ">=16.13.1", 50 | "react-markdown": "^4.3.1", 51 | "react-router-dom": "^5.2.0" 52 | }, 53 | "prettier": { 54 | "printWidth": 100, 55 | "semi": false, 56 | "singleQuote": true, 57 | "trailingComma": "es5", 58 | "jsxBracketSameLine": true, 59 | "bracketSpacing": false, 60 | "arrowParens": "avoid" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /frontend/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | kakoune web ui 7 | 8 | 9 |
10 | 11 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /frontend/snowpack.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "mount": { 3 | "public": "/", 4 | "src": "/_dist_" 5 | }, 6 | "plugins": [ 7 | "@snowpack/plugin-react-refresh", 8 | "@snowpack/plugin-babel", 9 | "@snowpack/plugin-dotenv" 10 | ], 11 | "installOptions": { 12 | "polyfillNode": true 13 | }, 14 | "devOptions": { 15 | "port": 1235 16 | }, 17 | "proxy": { 18 | "/api": "http://localhost:8234/", 19 | "/kak": { 20 | "target": "http://localhost:8234/", 21 | "ws": true 22 | } 23 | }, 24 | "alias": { 25 | "@app": "./src" 26 | }, 27 | "exclude": [ 28 | "**/node_modules/**/*", 29 | "**/__tests__/*", 30 | "**/*.@(spec|test).@(js|mjs)", 31 | "**/*doctest*" 32 | ] 33 | } 34 | -------------------------------------------------------------------------------- /frontend/src/Kakoune.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import {div, pre, css} from './div' 3 | import {css as emotion_css, cx} from 'emotion' 4 | import {Global, css as core_css} from '@emotion/core' 5 | 6 | export function template_to_string(value: TemplateStringsArray | string, ...more: any[]) { 7 | if (typeof value == 'string' || typeof value == 'number') { 8 | return value 9 | } 10 | return value.map((s, i) => s + (more[i] === undefined ? '' : more[i])).join('') 11 | } 12 | 13 | export function forward( 14 | f: (...args: Args) => B, 15 | g: (b: B) => C 16 | ): (...args: Args) => C { 17 | return (...args) => g(f(...args)) 18 | } 19 | 20 | export function MakeAttr(attr: Attr) { 21 | return forward(template_to_string, value => ({[attr]: value})) 22 | } 23 | 24 | export const style = MakeAttr('style') 25 | export const cls = MakeAttr('className') 26 | export const id = MakeAttr('id') 27 | 28 | const NAMED_KEYS: Record = { 29 | Enter: 'ret', 30 | Tab: 'tab', 31 | Backspace: 'backspace', 32 | Delete: 'del', 33 | Escape: 'esc', 34 | ArrowUp: 'up', 35 | ArrowDown: 'down', 36 | ArrowLeft: 'left', 37 | ArrowRight: 'right', 38 | PageUp: 'pageup', 39 | PageDown: 'pagedown', 40 | Home: 'home', 41 | End: 'end', 42 | F1: 'f1', 43 | F2: 'f2', 44 | F3: 'f3', 45 | F4: 'f4', 46 | F5: 'f5', 47 | F6: 'f6', 48 | F7: 'f7', 49 | F8: 'f8', 50 | F9: 'f9', 51 | F10: 'f10', 52 | F11: 'f11', 53 | F12: 'f12', 54 | '>': 'gt', 55 | '<': 'lt', 56 | '-': 'minus', 57 | } 58 | 59 | // eighties 60 | const NAMED_COLOURS: Record = { 61 | black: '#2d2d2d', 62 | 'bright-green': '#393939', 63 | 'bright-yellow': '#515151', 64 | 'bright-black': '#747369', 65 | 'bright-blue': '#a09f93', 66 | white: '#d3d0c8', 67 | 'bright-magenta': '#e8e6df', 68 | 'bright-white': '#f2f0ec', 69 | red: '#f2777a', 70 | 'bright-red': '#f99157', 71 | yellow: '#ffcc66', 72 | green: '#99cc99', 73 | cyan: '#66cccc', 74 | blue: '#6699cc', 75 | magenta: '#cc99cc', 76 | 'bright-cyan': '#d27b53', 77 | } || { 78 | black: 'rgb(00.0%, 00.0%, 00.0%)', 79 | red: 'rgb(80.0%, 00.0%, 00.0%)', 80 | green: 'rgb(30.6%, 60.4%, 02.4%)', 81 | yellow: 'rgb(76.9%, 62.7%, 00.0%)', 82 | blue: 'rgb(20.4%, 39.6%, 64.3%)', 83 | magenta: 'rgb(45.9%, 31.4%, 48.2%)', 84 | cyan: 'rgb(02.4%, 59.6%, 60.4%)', 85 | white: 'rgb(82.7%, 84.3%, 81.2%)', 86 | 'bright-black': 'rgb(33.3%, 34.1%, 32.5%)', 87 | 'bright-red': 'rgb(93.7%, 16.1%, 16.1%)', 88 | 'bright-green': 'rgb(54.1%, 88.6%, 20.4%)', 89 | 'bright-yellow': 'rgb(98.8%, 91.4%, 31.0%)', 90 | 'bright-blue': 'rgb(44.7%, 62.4%, 81.2%)', 91 | 'bright-magenta': 'rgb(67.8%, 49.8%, 65.9%)', 92 | 'bright-cyan': 'rgb(20.4%, 88.6%, 88.6%)', 93 | 'bright-white': 'rgb(93.3%, 93.3%, 92.5%)', 94 | } 95 | 96 | interface Face { 97 | fg: string 98 | bg: string 99 | attributes: string[] 100 | } 101 | 102 | interface Atom { 103 | face: Face 104 | contents: string 105 | } 106 | 107 | const atoms_text = (atoms: Atom[]) => atoms.map(atom => atom.contents).join('') 108 | 109 | function color_to_css(name: string, fallback?: string): string { 110 | // use class cache? 111 | if (fallback && (name == 'default' || name == '')) { 112 | return color_to_css(fallback) 113 | } else if (name in NAMED_COLOURS) { 114 | return NAMED_COLOURS[name] 115 | } else { 116 | return name 117 | } 118 | } 119 | 120 | const global_style = { 121 | styles: core_css` 122 | pre { 123 | margin: 0; 124 | } 125 | pre, body { 126 | font-size: 12px; 127 | font-family: 'Consolas'; 128 | letter-spacing: -0.025em; 129 | } 130 | body { 131 | margin: 0; 132 | overflow: hidden; 133 | user-select: none; 134 | } 135 | `, 136 | } 137 | 138 | const GlobalStyle = () => 139 | 140 | function bg(face: Face) { 141 | return css(`background: ${color_to_css(face.bg, 'white')}`) 142 | } 143 | 144 | function face_to_style(face: Face, default_face?: Face) { 145 | return css` 146 | color: ${color_to_css(face.fg, (default_face || {}).fg)}; 147 | background: ${color_to_css(face.bg, (default_face || {}).bg)}; 148 | ` 149 | } 150 | 151 | const Main = 'Main' 152 | const Line = 'Line' 153 | const ContentBlock = 'ContentBlock' 154 | const ContentInline = 'ContentInline' 155 | 156 | function Editor({websocket}: {websocket: WebSocket}) { 157 | const [state, set_state] = React.useState({} as any) 158 | const [root, set_root] = React.useState(null as null | HTMLElement) 159 | 160 | const send = React.useCallback( 161 | function send(method, ...params) { 162 | const msg = {jsonrpc: '2.0', method, params} 163 | if (websocket.readyState == websocket.OPEN) { 164 | websocket.send(JSON.stringify(msg)) 165 | } else if (websocket.readyState == websocket.CONNECTING) { 166 | const prev = websocket.onopen 167 | websocket.onopen = ev => { 168 | prev && prev.bind(websocket)(ev) 169 | websocket.send(JSON.stringify(msg)) 170 | } 171 | } 172 | }, 173 | [websocket] 174 | ) 175 | 176 | const Left = css` 177 | position: absolute; 178 | left: 0; 179 | bottom: 0; 180 | ` 181 | const Right = css` 182 | position: absolute; 183 | right: 0; 184 | bottom: 0; 185 | ` 186 | const FlexColumnRight = css` 187 | display: flex; 188 | flex-direction: column; 189 | align-items: flex-end; 190 | ` 191 | const FlexColumnLeft = css` 192 | display: flex; 193 | flex-direction: column; 194 | align-items: flex-start; 195 | ` 196 | const InlineFlexRowTop = css` 197 | display: inline-flex; 198 | flex-direction: row; 199 | align-items: flex-start; 200 | ` 201 | const FlexRowTop = css` 202 | display: flex; 203 | flex-direction: row; 204 | align-items: flex-start; 205 | ` 206 | const WideChildren = css` 207 | & * { 208 | width: 100%; 209 | } 210 | ` 211 | 212 | const mouse = [ 213 | {handler: 'onMouseDown', message: 'press_left'}, 214 | {handler: 'onMouseMove', message: 'move'}, 215 | {handler: 'onMouseUp', message: 'release_left'}, 216 | ] 217 | const mouse_handlers = (line: number, what_col: (e: MouseEvent) => void) => 218 | (line === undefined ? [] : mouse).map(({handler, message}) => ({ 219 | [handler]: (e: MouseEvent) => { 220 | if (!e.buttons || e.button) { 221 | return 222 | } 223 | e.preventDefault() 224 | e.stopPropagation() 225 | send('mouse', message, line, what_col(e)) 226 | }, 227 | })) 228 | 229 | function markup_atoms(default_face: Face) { 230 | const empty_atom = [{face: {fg: 'default', bg: 'default', attributes: []}, contents: ' '}] 231 | const is_empty = (atoms: Atom[]) => !atoms || (atoms.length == 1 && !atoms[0].contents) 232 | const ensure_nonempty = (atoms: Atom[]) => (is_empty(atoms) ? empty_atom : atoms) 233 | return function atoms_markup(atoms: Atom[], line: number) { 234 | return div( 235 | {className: Line}, 236 | div( 237 | {className: ContentBlock}, 238 | ...mouse_handlers(line, _ => atoms_text(atoms).length), 239 | div( 240 | {className: ContentInline}, 241 | InlineFlexRowTop, 242 | ...mouse_handlers(line, e => { 243 | const node = e.currentTarget 244 | if (!node || !(node instanceof HTMLElement)) { 245 | return 246 | } 247 | const x = e.clientX - node.offsetLeft 248 | const w = node.clientWidth / (node.textContent || ' ').length // assuming constant width 249 | return Math.floor(x / w) 250 | }), 251 | ...ensure_nonempty(atoms).map(cell => 252 | pre(face_to_style(cell.face, default_face), cell.contents.replace(/\n/g, ' ')) 253 | ) 254 | ) 255 | ) 256 | ) 257 | } 258 | } 259 | 260 | function component() { 261 | if (!state.main || !state.status) { 262 | return Nothing yet 263 | } 264 | 265 | let info_prompt, info_inline 266 | if (state.info) { 267 | const [title, content, anchor, face, info_style] = state.info 268 | const dom = div( 269 | css` 270 | padding: 6px; 271 | `, 272 | face_to_style(face), 273 | ...content.map(markup_atoms(face)) 274 | ) 275 | if (info_style == 'prompt') { 276 | info_prompt = dom 277 | } else if (info_style == 'menuDoc') { 278 | info_inline = dom 279 | } else { 280 | console.warn('Unsupported info style', info_style) 281 | } 282 | } 283 | 284 | let menu_dom, menu_prompt, menu_line 285 | if (state.menu) { 286 | const [items, anchor, selected_face, face, menu_style] = state.menu 287 | menu_dom = items 288 | .slice(0, state.rows - 3) 289 | .map((item, i) => 290 | markup_atoms(i == (state.selected || [-1])[0] ? selected_face : face)(item) 291 | ) 292 | if (menu_style == 'prompt' || menu_style == 'search') { 293 | menu_prompt = div( 294 | WideChildren, 295 | css` 296 | display: inline-block; 297 | `, 298 | bg(face), 299 | ...menu_dom 300 | ) 301 | } else if (menu_style == 'inline') { 302 | menu_line = anchor.line 303 | } else { 304 | console.warn('Unsupported menu style', menu_style) 305 | } 306 | } 307 | 308 | const [lines, default_face, padding_face] = state.main 309 | const rendered_lines = lines.map(markup_atoms(default_face)) 310 | const main = div({className: Main}, bg(default_face), ...rendered_lines) 311 | 312 | const [status_line, status_mode_line, status_default_face] = state.status 313 | const status = div(markup_atoms(status_default_face)(status_line)) 314 | const mode_line = div(markup_atoms(status_default_face)(status_mode_line)) 315 | 316 | return ( 317 |
set_root(div)} 319 | className={cx( 320 | emotion_css` 321 | height: 100vh; 322 | width: 100vw; 323 | overflow: hidden; 324 | ${bg(padding_face).css as any} 325 | ` 326 | )}> 327 | {main} 328 | {div( 329 | Left, 330 | css` 331 | width: 100vw; 332 | `, 333 | div( 334 | Left, 335 | FlexColumnLeft, 336 | css` 337 | z-index: 3; 338 | `, 339 | menu_prompt, 340 | status 341 | ), 342 | div( 343 | Right, 344 | FlexColumnRight, 345 | css` 346 | z-index: 2; 347 | `, 348 | info_prompt, 349 | mode_line 350 | ) 351 | )} 352 |
353 | ) 354 | } 355 | 356 | function send_resize(root: HTMLElement) { 357 | const lines = root.querySelectorAll(`.${Main} > .${Line}`) 358 | if (!lines) return 359 | const root_rect = root.getBoundingClientRect() 360 | const columns: number[] = [] 361 | let next_rows = 0 362 | const H = lines.length 363 | lines.forEach(function line_size(line, h) { 364 | const block = line.querySelector('.' + ContentBlock) 365 | const inline = line.querySelector('.' + ContentInline) 366 | if (!block || !inline) { 367 | return 368 | } 369 | const line_rect = line.getBoundingClientRect() 370 | const block_rect = block.getBoundingClientRect() 371 | const inline_rect = inline.getBoundingClientRect() 372 | 373 | const cell_width = inline_rect.width / (inline.textContent?.length || 1) 374 | const block_width = Math.min(block_rect.right, line_rect.right) - block_rect.left 375 | columns.push(Math.floor(block_width / cell_width)) 376 | 377 | const slack_bottom = line_rect.top + block_rect.height 378 | 379 | if (slack_bottom <= root_rect.bottom) { 380 | if (h == H - 1) { 381 | const more = Math.floor((root_rect.bottom - line_rect.bottom) / block_rect.height) 382 | if (more >= 0) { 383 | next_rows = H + more 384 | } else { 385 | next_rows = h + 1 386 | } 387 | } else { 388 | next_rows = h + 1 389 | } 390 | } 391 | }) 392 | const next_cols = Math.min(...columns) 393 | 394 | send('resize', next_rows + 1, next_cols) 395 | } 396 | 397 | React.useEffect(() => { 398 | if (root) { 399 | const obs = new ResizeObserver(entries => { 400 | send_resize(entries[0].target) 401 | }) 402 | obs.observe(root) 403 | return () => obs.disconnect() 404 | } 405 | }, [root, send]) 406 | 407 | const shows: Record = { 408 | menu_show: 'menu', 409 | info_show: 'info', 410 | menu_select: 'selected', 411 | draw: 'main', 412 | draw_status: 'status', 413 | set_cursor: 'cursor', 414 | } 415 | 416 | const hides: Record = { 417 | menu_hide: ['menu', 'selected'], 418 | info_hide: ['info'], 419 | } 420 | 421 | React.useEffect(() => { 422 | websocket.onmessage = function on_message(msg) { 423 | interface Message { 424 | method: string 425 | params: any[] 426 | } 427 | const messages: Message[] = JSON.parse(msg.data) 428 | messages.forEach(({method, params}) => { 429 | // method === 'draw' || console.log(JSON.stringify({method, params}, undefined, 2).slice(0, 400)) 430 | if (method === 'set_ui_options') { 431 | // pass 432 | } else if (method in hides) { 433 | hides[method].forEach(field => (state[field] = undefined)) 434 | } else if (method in shows) { 435 | state[shows[method]] = params 436 | } else { 437 | console.warn('unsupported', method, JSON.stringify(params)) 438 | } 439 | }) 440 | set_state({...state}) 441 | } 442 | 443 | function mod(k: string, e: KeyboardEvent) { 444 | let s = k 445 | if (e.altKey) s = 'a-' + s 446 | if (e.ctrlKey) s = 'c-' + s 447 | if (s == 'c-i') s = 'tab' 448 | if (e.shiftKey && s == 'tab') s = 's-tab' 449 | if (s == 'c-h') s = 'backspace' 450 | return s.length == 1 ? s : `<${s}>` 451 | } 452 | 453 | const onkeydown = (e: KeyboardEvent) => { 454 | // console.log(e) 455 | e.preventDefault() 456 | const key = e.key 457 | if (key in NAMED_KEYS) { 458 | send('keys', mod(NAMED_KEYS[key], e)) 459 | } else if (key.length == 1) { 460 | send('keys', mod(key, e)) 461 | } 462 | return false 463 | } 464 | 465 | const onmousewheel = (e: Event) => { 466 | // e.preventDefault() 467 | if (e instanceof WheelEvent && e.deltaY) { 468 | send('scroll', e.deltaY < 0 ? -1 : 1) 469 | } 470 | } 471 | 472 | window.addEventListener('keydown', onkeydown) 473 | window.addEventListener('mousewheel', onmousewheel) 474 | 475 | return () => { 476 | window.removeEventListener('keydown', onkeydown) 477 | window.removeEventListener('mousewheel', onmousewheel) 478 | } 479 | }, [websocket, send]) 480 | 481 | return ( 482 | <> 483 | 484 | {component()} 485 | 486 | ) 487 | } 488 | 489 | export function Kakoune() { 490 | const [ws, set_ws] = React.useState(undefined as undefined | WebSocket) 491 | React.useEffect(() => { 492 | const make_ws = async () => { 493 | const response = await fetch('http://' + window.location.host + '/api/sessions') 494 | const {sessions} = await response.json() 495 | console.info('Sessions:', ...sessions) 496 | const ws = new WebSocket('ws://' + window.location.host + '/kak/' + sessions[0]) 497 | console.log('ws:', ws) 498 | set_ws(ws) 499 | } 500 | make_ws() 501 | }, []) 502 | React.useEffect(() => { 503 | return () => { 504 | if (ws) { 505 | console.log('closing ws') 506 | ws.close() 507 | } 508 | } 509 | }, [ws]) 510 | return ws ? : null 511 | } 512 | -------------------------------------------------------------------------------- /frontend/src/div.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | import {ClassNames, Interpolation} from '@emotion/core' 4 | 5 | export function dummy_keys(xs: React.ReactNode[], prefix = ';'): React.ReactElement { 6 | return ( 7 | <> 8 | {xs.map((x, i) => { 9 | if (x && typeof x == 'object' && '$$typeof' in x) { 10 | let child = x as any 11 | if (!child.key) { 12 | const key = prefix + i 13 | const ref = child.ref 14 | child = React.createElement(child.type, {key, ref, ...child.props}) 15 | } 16 | return child 17 | } else { 18 | return x 19 | } 20 | })} 21 | 22 | ) 23 | } 24 | 25 | export function css( 26 | template: TemplateStringsArray | string | Interpolation, 27 | ...args: Interpolation[] 28 | ): {css: unknown} { 29 | return {css: [template, ...args]} 30 | } 31 | 32 | export type DivProps = {key?: string} & {css?: unknown} & React.HTMLAttributes & 33 | React.RefAttributes 34 | 35 | export function Tag( 36 | tagName: TagName, 37 | ...args: (DivProps | {css: unknown} | React.ReactNode)[] 38 | ) { 39 | const props: Record = { 40 | children: [], 41 | css: [], 42 | } 43 | args.forEach(function add(arg) { 44 | if (typeof arg == 'string' || typeof arg == 'number') { 45 | props.children.push(arg) 46 | } else if (arg && typeof arg == 'object') { 47 | if ('$$typeof' in arg) { 48 | props.children.push(arg) 49 | } else if (Array.isArray(arg)) { 50 | arg.forEach(add) 51 | } else { 52 | Object.entries(arg).forEach(([k, v]) => { 53 | if (k == 'css') { 54 | props.css.push(v) 55 | } else if (k == 'children') { 56 | props.children.push(...v) 57 | } else if (typeof v == 'function') { 58 | const prev = props[k] 59 | if (prev) { 60 | props[k] = (...args: any[]) => { 61 | prev(...args) 62 | v(...args) 63 | } 64 | } else { 65 | props[k] = v 66 | } 67 | } else if (typeof v == 'object') { 68 | props[k] = {...props[k], ...v} 69 | } else { 70 | if (props[k]) { 71 | props[k] += ' ' 72 | } else { 73 | props[k] = '' 74 | } 75 | props[k] += v 76 | } 77 | }) 78 | } 79 | } 80 | }) 81 | props.children = dummy_keys(props.children, ':') 82 | const {css: props_css, key, ...normal_props} = props 83 | if (props_css.length) { 84 | return ( 85 | 86 | {({css, cx}) => 87 | React.createElement(tagName, { 88 | ...normal_props, 89 | className: cx( 90 | normal_props.className, 91 | props_css.map((xs: any[]) => css(...xs)) 92 | ), 93 | }) 94 | } 95 | 96 | ) 97 | } else { 98 | return React.createElement(tagName, {...normal_props, key}) 99 | } 100 | } 101 | 102 | export function div(...args: (DivProps | {css: unknown} | React.ReactNode)[]) { 103 | return Tag('div', ...args) 104 | } 105 | 106 | export function pre(...args: (DivProps | {css: unknown} | React.ReactNode)[]) { 107 | return Tag('pre', ...args) 108 | } 109 | -------------------------------------------------------------------------------- /frontend/src/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import * as ReactDOM from 'react-dom' 3 | import {Kakoune} from './Kakoune' 4 | 5 | const root = 6 | 7 | ReactDOM.render(root, document.getElementById('root')) 8 | 9 | if (import.meta.hot) { 10 | // Hot Module Replacement (HMR) - Remove this snippet to remove HMR. 11 | // Learn more: https://www.snowpack.dev/#hot-module-replacement 12 | import.meta.hot.accept() 13 | } 14 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@snowpack/app-scripts-react/tsconfig.base.json", 3 | "compilerOptions": { 4 | "lib": ["es2019", "dom"], 5 | "sourceMap": true, 6 | "noImplicitAny": true, 7 | "strictNullChecks": true, 8 | "strictFunctionTypes": true, 9 | "strictBindCallApply": true, 10 | "strictPropertyInitialization": true, 11 | "noImplicitThis": true, 12 | "alwaysStrict": true, 13 | 14 | "downlevelIteration": true, 15 | 16 | "noUnusedLocals": false, 17 | "noUnusedParameters": false, 18 | "noImplicitReturns": false, 19 | "noFallthroughCasesInSwitch": true, 20 | 21 | "importsNotUsedAsValues": "remove", 22 | 23 | "esModuleInterop": true, 24 | 25 | "baseUrl": ".", 26 | "paths": { 27 | "@app/*": ["src/*"] 28 | } 29 | }, 30 | "include": ["src", "test"], 31 | "exclude": ["coverage", "node_modules"] 32 | } 33 | -------------------------------------------------------------------------------- /kak_web_ui.py: -------------------------------------------------------------------------------- 1 | # !pip install aiohttp --user 2 | # !pip install aionotify --user 3 | 4 | import asyncio 5 | import aionotify 6 | import aiohttp 7 | from aiohttp import web 8 | from asyncio.subprocess import PIPE 9 | 10 | import os 11 | import logging 12 | import sys 13 | 14 | app = web.Application() 15 | routes = web.RouteTableDef() 16 | 17 | @routes.get('/kak/{session}') 18 | async def kak_json_websocket(request): 19 | websocket = web.WebSocketResponse() 20 | await websocket.prepare(request) 21 | 22 | session = request.match_info['session'] 23 | print(session) 24 | kak = await asyncio.create_subprocess_exec( 25 | 'kak', '-c', str(session).rstrip(), '-ui', 'json', 26 | stdin=PIPE, stdout=PIPE, 27 | limit=1024*1024*1024) # 1GB 28 | 29 | H = len('{ "jsonrpc": "2.0", "method": "refresh"') 30 | async def fwd(): 31 | buf = [] 32 | async for message in kak.stdout: 33 | if websocket.closed: 34 | kak.terminate() 35 | await kak.wait() 36 | break 37 | if b'refresh' in message[0:H]: 38 | await websocket.send_str('['+','.join(buf) + ']') 39 | buf = [] 40 | else: 41 | buf.append(message.decode()) 42 | 43 | asyncio.create_task(fwd()) 44 | 45 | async for msg in websocket: 46 | if msg.type == aiohttp.WSMsgType.TEXT: 47 | msg = msg.data 48 | # print(msg.encode()) 49 | kak.stdin.write(msg.encode()) 50 | 51 | return websocket 52 | 53 | @routes.get('/sessions') 54 | async def kak_sessions(request): 55 | kak = await asyncio.create_subprocess_exec('kak', '-l', stdout=PIPE) 56 | sessions = await kak.stdout.read() 57 | await kak.wait() 58 | return web.json_response({'sessions': sessions.decode().split()}) 59 | 60 | app.router.add_routes(routes) 61 | 62 | def main(): 63 | logging.basicConfig(level=logging.DEBUG) 64 | try: 65 | port = int(sys.argv[1]) 66 | except: 67 | port = 8234 68 | web.run_app(app, host='127.0.0.1', port=port, access_log_format='%t %a %s %r') 69 | 70 | if __name__ == '__main__': 71 | main() 72 | -------------------------------------------------------------------------------- /requirements.in: -------------------------------------------------------------------------------- 1 | aionotify 2 | aiohttp 3 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile 3 | # To update, run: 4 | # 5 | # pip-compile requirements.in 6 | # 7 | aiohttp==3.6.2 8 | aionotify==0.2.0 9 | async-timeout==3.0.1 # via aiohttp 10 | attrs==19.3.0 # via aiohttp 11 | chardet==3.0.4 # via aiohttp 12 | idna==2.8 # via yarl 13 | multidict==4.7.4 # via aiohttp, yarl 14 | yarl==1.4.2 # via aiohttp 15 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from setuptools import setup, find_packages 3 | 4 | try: 5 | long_description = open("README.md").read() 6 | except IOError: 7 | long_description = "" 8 | 9 | setup( 10 | name="kak-web-ui", 11 | version="0.1.0", 12 | description="Run the kakoune text editor in your browser", 13 | license="MIT", 14 | author="Dan Rosén", 15 | packages=['.'], 16 | entry_points={ 17 | 'console_scripts': [ 18 | 'kak-web-ui = kak_web_ui:main', 19 | ] 20 | }, 21 | py_modules=["kak_web_ui"], 22 | package_data={ 23 | '.': ["*.js"], 24 | }, 25 | install_requires=[ r for r in open("requirements.txt").read().split('\n') if not r.startswith('#') ], 26 | long_description=long_description, 27 | classifiers=[ 28 | "Programming Language :: Python", 29 | "Programming Language :: Python :: 3.8", 30 | "Programming Language :: Python :: 3.7", 31 | ] 32 | ) 33 | --------------------------------------------------------------------------------