= {
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 |
--------------------------------------------------------------------------------