aboutsummaryrefslogtreecommitdiff
path: root/xP
diff options
context:
space:
mode:
Diffstat (limited to 'xP')
-rw-r--r--xP/.gitignore4
-rw-r--r--xP/Makefile18
-rw-r--r--xP/gen-ircfmt.awk89
-rw-r--r--xP/go.mod10
-rw-r--r--xP/go.sum62
-rw-r--r--xP/public/ircfmt.woff2bin0 -> 1240 bytes
-rw-r--r--xP/public/xP.css257
-rw-r--r--xP/public/xP.js1108
-rw-r--r--xP/xP.go299
9 files changed, 1847 insertions, 0 deletions
diff --git a/xP/.gitignore b/xP/.gitignore
new file mode 100644
index 0000000..ba4d8c3
--- /dev/null
+++ b/xP/.gitignore
@@ -0,0 +1,4 @@
+/xP
+/proto.go
+/public/proto.js
+/public/mithril.js
diff --git a/xP/Makefile b/xP/Makefile
new file mode 100644
index 0000000..34de55a
--- /dev/null
+++ b/xP/Makefile
@@ -0,0 +1,18 @@
+.POSIX:
+.SUFFIXES:
+
+outputs = xP proto.go public/proto.js public/mithril.js
+all: $(outputs) public/ircfmt.woff2
+
+xP: xP.go proto.go
+ go build -o $@
+proto.go: ../xC-gen-proto.awk ../xC-gen-proto-go.awk ../xC-proto
+ awk -f ../xC-gen-proto.awk -f ../xC-gen-proto-go.awk ../xC-proto > $@
+public/proto.js: ../xC-gen-proto.awk ../xC-gen-proto-js.awk ../xC-proto
+ awk -f ../xC-gen-proto.awk -f ../xC-gen-proto-js.awk ../xC-proto > $@
+public/ircfmt.woff2: gen-ircfmt.awk
+ awk -v Output=$@ -f gen-ircfmt.awk
+public/mithril.js:
+ curl -Lo $@ https://unpkg.com/mithril/mithril.js
+clean:
+ rm -f $(outputs)
diff --git a/xP/gen-ircfmt.awk b/xP/gen-ircfmt.awk
new file mode 100644
index 0000000..cc9a5a0
--- /dev/null
+++ b/xP/gen-ircfmt.awk
@@ -0,0 +1,89 @@
+# gen-ircfmt.awk: generate a supplementary font for IRC formatting characters
+#
+# Copyright (c) 2022, Přemysl Eric Janouch <p@janouch.name>
+# SPDX-License-Identifier: 0BSD
+#
+# Usage: awk -v Output=static/ircfmt.woff2 -f gen-ircfmt.awk
+# Clean up SVG byproducts yourself.
+
+BEGIN {
+ if (!Output) {
+ print "Error: you must specify the output filename"
+ exit 1
+ }
+}
+
+function glyph(name, code, path, filename, actions, cmd) {
+ filename = Output "." name ".svg"
+
+ # Inkscape still does a terrible job at the stroke-to-path conversion.
+ actions = \
+ "select-by-id:group;" \
+ "selection-ungroup;" \
+ "select-clear;" \
+ "select-by-id:path;" \
+ "object-stroke-to-path;" \
+ "select-by-id:clip;" \
+ "path-intersection;" \
+ "select-all;" \
+ "path-combine;" \
+ "export-overwrite;" \
+ "export-filename:" filename ";" \
+ "export-do"
+
+ # These dimensions fit FontForge defaults, and happen to work well.
+ cmd = "inkscape --pipe --actions='" actions "'"
+ print "<?xml version='1.0' encoding='UTF-8' standalone='no'?>\n" \
+ "<svg version='1.1' xmlns='http://www.w3.org/2000/svg'\n" \
+ " width='1000' height='1000' viewBox='0 0 1000 1000'>\n" \
+ " <rect x='0' y='0' width='1000' height='1000' />\n" \
+ " <g id='group' transform='translate(200 200) scale(60, 60)'>\n" \
+ " <rect id='clip' x='0' y='0' width='10' height='10' />\n" \
+ " <path id='path' stroke-width='2' fill='none' stroke='#fff'\n" \
+ " d='" path "' />\n" \
+ " </g>\n" \
+ "</svg>\n" | cmd
+ close(cmd)
+
+ print "Select(0u" code ")\n" \
+ "Import('" filename "')" | FontForge
+}
+
+BEGIN {
+ FontForge = "fontforge -lang=ff -"
+ print "New()" | FontForge
+
+ # Designed a 10x10 raster, going for maximum simplicity.
+ glyph("B", "02", "m 6,5 c 0,0 2,0 2,2 0,2 -2,2 -2,2 h -3 v -8 h 2.5 c 0,0 2,0 2,2 0,2 -2,2 -2,2 h -2 Z")
+ glyph("C", "03", "m 7.6,7 A 3,4 0 0 1 4.25,8.875 3,4 0 0 1 2,5 3,4 0 0 1 4.25,1.125 3,4 0 0 1 7.6,3")
+ glyph("I", "1D", "m 3,9 h 4 m 0,-8 h -4 m 2,-1 v 10")
+ glyph("M", "11", "m 2,10 v -10 l 3,6 3,-6 v 10")
+ glyph("O", "0F", "m 1,9 l 8,-8 M 2,5 a 3,3 0 1 0 6,0 3,3 0 1 0 -6,0 z")
+ #glyph("R", "0F", "m 3,10 v -9 h 2 c 0,0 2.5,0 2.5,2.5 0,2.5 -2.5,2.5 -2.5,2.5 h -2 2.5 l 2.5,4.5")
+ glyph("S", "1E", "m 7.5,3 c 0,-1 -1,-2 -2.5,-2 -1.5,0 -2.5,1 -2.5,2 0,3 5,1 5,4 0,1 -1,2 -2.5,2 -1.5,0 -2.5,-1 -2.5,-2")
+ glyph("U", "1F", "m 2.5,0 v 6.5 c 0,1.5 1,2.5 2.5,2.5 1.5,0 2.5,-1 2.5,-2.5 v -6.5")
+ glyph("V", "16", "m 2,-1 3,11 3,-11")
+
+ # In practice, your typical browser font will overshoot its em box,
+ # so to make the display more cohesive, we need to do the same.
+ # Sadly, sf->use_typo_metrics can't be unset from FontForge script--
+ # this is necessary to prevent the caret from jumping upon the first
+ # inserted non-formatting character in xP's textarea.
+ # https://iamvdo.me/en/blog/css-font-metrics-line-height-and-vertical-align
+ print "SelectAll()\n" \
+ "Scale(115, 115, 0, 0)\n" \
+ "SetOS2Value('WinAscentIsOffset', 1)\n" \
+ "SetOS2Value('WinDescentIsOffset', 1)\n" \
+ "SetOS2Value('HHeadAscentIsOffset', 1)\n" \
+ "SetOS2Value('HHeadDescentIsOffset', 1)\n" \
+ "CorrectDirection()\n" \
+ "AutoWidth(100)\n" \
+ "AutoHint()\n" \
+ "AddExtrema()\n" \
+ "RoundToInt()\n" \
+ "SetFontNames('IRCFormatting-Regular'," \
+ " 'IRC Formatting', 'IRC Formatting Regular', 'Regular'," \
+ " 'Copyright (c) 2022, Premysl Eric Janouch')\n" \
+ "Generate('" Output "')\n" | FontForge
+ close(FontForge)
+}
diff --git a/xP/go.mod b/xP/go.mod
new file mode 100644
index 0000000..70cc10d
--- /dev/null
+++ b/xP/go.mod
@@ -0,0 +1,10 @@
+module janouch.name/xK/xP
+
+go 1.18
+
+require nhooyr.io/websocket v1.8.7
+
+require (
+ github.com/klauspost/compress v1.15.9 // indirect
+ golang.org/x/sys v0.0.0-20210423082822-04245dca01da // indirect
+)
diff --git a/xP/go.sum b/xP/go.sum
new file mode 100644
index 0000000..c94a673
--- /dev/null
+++ b/xP/go.sum
@@ -0,0 +1,62 @@
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
+github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
+github.com/gin-gonic/gin v1.6.3 h1:ahKqKTFpO5KTPHxWZjEdPScmYaGtLo8Y4DMHoEsnp14=
+github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M=
+github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
+github.com/go-playground/locales v0.13.0 h1:HyWk6mgj5qFqCT5fjGBuRArbVDfE4hi8+e8ceBS/t7Q=
+github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8=
+github.com/go-playground/universal-translator v0.17.0 h1:icxd5fm+REJzpZx7ZfpaD876Lmtgy7VtROAbHHXk8no=
+github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA=
+github.com/go-playground/validator/v10 v10.2.0 h1:KgJ0snyC2R9VXYN2rneOtQcw5aHQB1Vv0sFl1UcHBOY=
+github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GOhaH6EGOAJShg8Id5JGkI=
+github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee h1:s+21KNqlpePfkah2I+gwHF8xmJWRjooY+5248k6m4A0=
+github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo=
+github.com/gobwas/pool v0.2.0 h1:QEmUOlnSjWtnpRGHF3SauEiOsy82Cup83Vf2LcMlnc8=
+github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
+github.com/gobwas/ws v1.0.2 h1:CoAavW/wd/kulfZmSIBt6p24n4j7tHgNVCjsfHVNUbo=
+github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM=
+github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
+github.com/golang/protobuf v1.3.5 h1:F768QJ1E9tib+q5Sc8MkdJi1RxLTbRcTf8LJV56aRls=
+github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=
+github.com/google/go-cmp v0.4.0 h1:xsAVV57WRhGj6kEIi8ReJzQlHHqcBYCElAvkovg3B/4=
+github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
+github.com/gorilla/websocket v1.4.1 h1:q7AeDBpnBk8AogcD4DSag/Ukw/KV+YhzLj2bP5HvKCM=
+github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
+github.com/json-iterator/go v1.1.9 h1:9yzud/Ht36ygwatGx56VwCZtlI/2AD15T1X2sjSuGns=
+github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
+github.com/klauspost/compress v1.10.3/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
+github.com/klauspost/compress v1.15.9 h1:wKRjX6JRtDdrE9qwa4b/Cip7ACOshUI4smpCQanqjSY=
+github.com/klauspost/compress v1.15.9/go.mod h1:PhcZ0MbTNciWF3rruxRgKxI5NkcHHrHUDtV4Yw2GlzU=
+github.com/leodido/go-urn v1.2.0 h1:hpXL4XnriNwQ/ABnpepYM/1vCLWNDfUNts8dX3xTG6Y=
+github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII=
+github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY=
+github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
+github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
+github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742 h1:Esafd1046DLDQ0W1YjYsBW+p8U2u7vzgW2SQVmlNazg=
+github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
+github.com/ugorji/go v1.1.7 h1:/68gy2h+1mWMrwZFeD1kQialdSzAb432dtpeJ42ovdo=
+github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw=
+github.com/ugorji/go/codec v1.1.7 h1:2SvQaVZ1ouYrrKKwoSk2pzd4A9evlKJb9oTL+OaLUSs=
+github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY=
+golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210423082822-04245dca01da h1:b3NXsE2LusjYGGjL5bxEVZZORm/YEFFrWFjR8eFrw/c=
+golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
+golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
+gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+nhooyr.io/websocket v1.8.7 h1:usjR2uOr/zjjkVMy0lW+PPohFok7PCow5sDjLgX4P4g=
+nhooyr.io/websocket v1.8.7/go.mod h1:B70DZP8IakI65RVQ51MsWP/8jndNma26DVA/nFSCgW0=
diff --git a/xP/public/ircfmt.woff2 b/xP/public/ircfmt.woff2
new file mode 100644
index 0000000..d4262bc
--- /dev/null
+++ b/xP/public/ircfmt.woff2
Binary files differ
diff --git a/xP/public/xP.css b/xP/public/xP.css
new file mode 100644
index 0000000..e8b28f2
--- /dev/null
+++ b/xP/public/xP.css
@@ -0,0 +1,257 @@
+@font-face {
+ src: url('ircfmt.woff2') format('woff2');
+ font-family: 'IRC Formatting';
+ font-weight: normal;
+ font-style: normal;
+}
+body {
+ margin: 0;
+ padding: 0;
+ /* Firefox only renders C0 within the textarea, why? */
+ font-family: 'IRC Formatting', sans-serif;
+ font-size: clamp(0.5rem, 2vw, 1rem);
+}
+.xP {
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ height: 100vh;
+ /* https://caniuse.com/viewport-unit-variants */
+ height: 100dvh;
+}
+
+.overlay {
+ padding: .6em .9em;
+ background: #eee;
+ border: 1px outset #eee;
+ position: absolute;
+ left: 50%;
+ top: 50%;
+ transform: translate(-50%, -50%);
+ text-align: center;
+ z-index: 1;
+}
+.title, .status {
+ padding: .05em .3em;
+ background: #eee;
+
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+ column-gap: .3em;
+
+ position: relative;
+ border-top: 3px solid #ccc;
+ border-bottom: 2px solid #888;
+}
+.title {
+ /* To approximate right-aligned space-between. */
+ flex-direction: row-reverse;
+}
+.title:before, .status:before {
+ content: " ";
+ position: absolute;
+ left: 0;
+ right: 0;
+ height: 2px;
+ top: -2px;
+ background: #fff;
+}
+.title:after, .status:after {
+ content: " ";
+ position: absolute;
+ left: 0;
+ right: 0;
+ height: 1px;
+ bottom: -1px;
+ background: #ccc;
+}
+
+.toolbar {
+ display: flex;
+ align-items: baseline;
+ margin-right: -.3em;
+}
+.indicator {
+ margin: 0 .3em;
+}
+button {
+ font: inherit;
+ background: transparent;
+ border: 1px solid transparent;
+ padding: 0 .3em;
+}
+button:focus {
+ border: 1px dotted #000;
+}
+button:hover {
+ border-left: 1px solid #fff;
+ border-top: 1px solid #fff;
+ border-right: 1px solid #888;
+ border-bottom: 1px solid #888;
+}
+button:hover:active {
+ border-left: 1px solid #888;
+ border-top: 1px solid #888;
+ border-right: 1px solid #fff;
+ border-bottom: 1px solid #fff;
+}
+
+.middle {
+ flex: auto;
+ display: flex;
+ flex-direction: row;
+ overflow: hidden;
+}
+
+.list {
+ overflow-y: auto;
+ border-right: 2px solid #ccc;
+ min-width: 10em;
+ flex-shrink: 0;
+}
+.item {
+ padding: .05em .3em;
+ cursor: default;
+}
+.item.highlighted {
+ color: #ff5f00;
+}
+.item.activity {
+ font-weight: bold;
+}
+.item.current {
+ font-style: italic;
+ background: #eee;
+}
+
+/* Only Firefox currently supports align-content: safe end, thus this. */
+.buffer-container {
+ flex: auto;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ position: relative;
+}
+.filler {
+ flex: auto;
+}
+.buffer {
+ display: grid;
+ grid-template-columns: max-content minmax(0, 1fr);
+ overflow-y: auto;
+}
+.log {
+ font-family: monospace;
+ overflow-y: auto;
+}
+.log, .content, .completions {
+ /* Note: https://bugs.chromium.org/p/chromium/issues/detail?id=1261435 */
+ white-space: break-spaces;
+ overflow-wrap: break-word;
+}
+.log, .buffer .content {
+ padding: .1em .3em;
+}
+
+.leaked {
+ opacity: 50%;
+}
+.date {
+ padding: .3em;
+ grid-column: span 2;
+ font-weight: bold;
+}
+.unread {
+ grid-column: span 2;
+ border-top: 1px solid #ff5f00;
+}
+.time {
+ padding: .1em .3em;
+ background: #f8f8f8;
+ color: #bbb;
+ border-right: 1px solid #ccc;
+}
+.time.hidden:after {
+ border-top: .2em dotted #ccc;
+ display: block;
+ width: 50%;
+ margin: 0 auto;
+ content: "";
+}
+.mark {
+ padding-right: .3em;
+ text-align: center;
+ display: inline-block;
+ min-width: 2em;
+}
+.mark.error {
+ color: red;
+}
+.mark.join {
+ color: green;
+}
+.mark.part {
+ color: red;
+}
+.mark.action {
+ color: darkred;
+}
+.content .b {
+ font-weight: bold;
+}
+.content .i {
+ font-style: italic;
+}
+.content .u {
+ text-decoration: underline;
+}
+.content .s {
+ text-decoration: line-through;
+}
+.content .m {
+ font-family: monospace;
+}
+
+.completions {
+ position: absolute;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: #fff;
+ padding: .05em .3em;
+ border-top: 1px solid #888;
+
+ max-height: 50%;
+ display: flex;
+ flex-flow: column wrap;
+ column-gap: .6em;
+ overflow-x: auto;
+}
+.input {
+ flex-shrink: 0;
+ border: 2px inset #eee;
+ overflow: hidden;
+ resize: vertical;
+ display: flex;
+}
+.input:focus-within {
+ border-color: #ff5f00;
+}
+.prompt {
+ padding: .05em .3em;
+ border-right: 1px solid #ccc;
+ background: #f8f8f8;
+ font-weight: bold;
+}
+textarea {
+ font: inherit;
+ padding: .05em .3em;
+ margin: 0;
+ border: 0;
+ flex-grow: 1;
+ resize: none;
+}
+textarea:focus {
+ outline: none;
+}
diff --git a/xP/public/xP.js b/xP/public/xP.js
new file mode 100644
index 0000000..3266063
--- /dev/null
+++ b/xP/public/xP.js
@@ -0,0 +1,1108 @@
+// Copyright (c) 2022, Přemysl Eric Janouch <p@janouch.name>
+// SPDX-License-Identifier: 0BSD
+import * as Relay from './proto.js'
+
+// ---- RPC --------------------------------------------------------------------
+
+class RelayRPC extends EventTarget {
+ constructor(url) {
+ super()
+ this.url = url
+ this.commandSeq = 0
+ }
+
+ connect() {
+ // We can't close the connection immediately, as that queues a task.
+ if (this.ws !== undefined)
+ throw "Already connecting or connected"
+
+ return new Promise((resolve, reject) => {
+ let ws = this.ws = new WebSocket(this.url)
+ ws.onopen = event => {
+ this._initialize()
+ resolve()
+ }
+ // It's going to be code 1006 with no further info.
+ ws.onclose = event => {
+ this.ws = undefined
+ reject()
+ }
+ })
+ }
+
+ _initialize() {
+ this.ws.binaryType = 'arraybuffer'
+ this.ws.onopen = undefined
+ this.ws.onmessage = event => {
+ this._process(event.data)
+ }
+ this.ws.onerror = event => {
+ this.dispatchEvent(new CustomEvent('error'))
+ }
+ this.ws.onclose = event => {
+ let message = "Connection closed: " +
+ event.reason + " (" + event.code + ")"
+ for (const seq in this.promised)
+ this.promised[seq].reject(message)
+
+ this.ws = undefined
+ this.dispatchEvent(new CustomEvent('close', {
+ detail: {message, code: event.code, reason: event.reason},
+ }))
+
+ // Now connect() can be called again.
+ }
+
+ this.promised = {}
+ }
+
+ _process(data) {
+ if (typeof data === 'string')
+ throw "JSON messages not supported"
+
+ const r = new Relay.Reader(data)
+ while (!r.empty)
+ this._processOne(Relay.EventMessage.deserialize(r))
+ }
+
+ _processOne(message) {
+ let e = message.data
+ switch (e.event) {
+ case Relay.Event.Error:
+ if (this.promised[e.commandSeq] !== undefined)
+ this.promised[e.commandSeq].reject(e.error)
+ else
+ console.error(`Unawaited error: ${e.error}`)
+ break
+ case Relay.Event.Response:
+ if (this.promised[e.commandSeq] !== undefined)
+ this.promised[e.commandSeq].resolve(e.data)
+ else
+ console.error("Unawaited response")
+ break
+ default:
+ e.eventSeq = message.eventSeq
+ this.dispatchEvent(new CustomEvent('event', {detail: e}))
+ return
+ }
+
+ delete this.promised[e.commandSeq]
+ for (const seq in this.promised) {
+ // We don't particularly care about wraparound issues.
+ if (seq >= e.commandSeq)
+ continue
+
+ this.promised[seq].reject("No response")
+ delete this.promised[seq]
+ }
+ }
+
+ send(params) {
+ if (this.ws === undefined)
+ throw "Not connected"
+ if (typeof params !== 'object')
+ throw "Method parameters must be an object"
+
+ // Left shifts in Javascript convert to a 32-bit signed number.
+ let seq = ++this.commandSeq
+ if ((seq << 0) != seq)
+ seq = this.commandSeq = 0
+
+ this.ws.send(JSON.stringify({commandSeq: seq, data: params}))
+
+ // Automagically detect if we want a result.
+ let data = undefined
+ const promise = new Promise(
+ (resolve, reject) => { data = {resolve, reject} })
+ promise.then = (...args) => {
+ this.promised[seq] = data
+ return Promise.prototype.then.call(promise, ...args)
+ }
+ return promise
+ }
+}
+
+// ---- Utilities --------------------------------------------------------------
+
+function utf8Encode(s) { return new TextEncoder().encode(s) }
+function utf8Decode(s) { return new TextDecoder().decode(s) }
+
+function hasShortcutModifiers(event) {
+ return (event.altKey || event.escapePrefix) &&
+ !event.metaKey && !event.ctrlKey
+}
+
+const audioContext = new AudioContext()
+
+function beep() {
+ let gain = audioContext.createGain()
+ gain.gain.value = 0.5
+ gain.connect(audioContext.destination)
+
+ let oscillator = audioContext.createOscillator()
+ oscillator.type = "triangle"
+ oscillator.frequency.value = 800
+ oscillator.connect(gain)
+ oscillator.start(audioContext.currentTime)
+ oscillator.stop(audioContext.currentTime + 0.1)
+}
+
+let iconLink = undefined
+let iconState = undefined
+
+function updateIcon(highlighted) {
+ if (iconState === highlighted)
+ return
+
+ iconState = highlighted
+ let canvas = document.createElement('canvas')
+ canvas.width = 32
+ canvas.height = 32
+
+ let ctx = canvas.getContext('2d')
+ ctx.arc(16, 16, 12, 0, 2 * Math.PI)
+ ctx.fillStyle = '#000'
+ if (highlighted === true)
+ ctx.fillStyle = '#ff5f00'
+ if (highlighted === false)
+ ctx.fillStyle = '#ccc'
+ ctx.fill()
+
+ if (iconLink === undefined) {
+ iconLink = document.createElement('link')
+ iconLink.type = 'image/png'
+ iconLink.rel = 'icon'
+ document.getElementsByTagName('head')[0].appendChild(iconLink)
+ }
+
+ iconLink.href = canvas.toDataURL();
+}
+
+// ---- Event processing -------------------------------------------------------
+
+let rpc = new RelayRPC(proxy)
+let rpcEventHandlers = new Map()
+
+let buffers = new Map()
+let bufferLast = undefined
+let bufferCurrent = undefined
+let bufferLog = undefined
+let bufferAutoscroll = true
+
+let servers = new Map()
+
+function bufferResetStats(b) {
+ b.newMessages = 0
+ b.newUnimportantMessages = 0
+ b.highlighted = false
+}
+
+function bufferActivate(name) {
+ rpc.send({command: 'BufferActivate', bufferName: name})
+}
+
+function bufferToggleUnimportant(name) {
+ rpc.send({command: 'BufferToggleUnimportant', bufferName: name})
+}
+
+function bufferToggleLog() {
+ // TODO: Try to restore the previous scroll offset.
+ if (bufferLog) {
+ setTimeout(() =>
+ document.getElementById('input')?.focus())
+
+ bufferLog = undefined
+ m.redraw()
+ return
+ }
+
+ let name = bufferCurrent
+ rpc.send({
+ command: 'BufferLog',
+ bufferName: name,
+ }).then(resp => {
+ if (bufferCurrent !== name)
+ return
+
+ bufferLog = utf8Decode(resp.log)
+ m.redraw()
+ })
+}
+
+let connecting = true
+rpc.connect().then(result => {
+ buffers.clear()
+ bufferLast = undefined
+ bufferCurrent = undefined
+ bufferLog = undefined
+ bufferAutoscroll = true
+
+ servers.clear()
+
+ rpc.send({command: 'Hello', version: Relay.version})
+ connecting = false
+ m.redraw()
+}).catch(error => {
+ connecting = false
+ m.redraw()
+})
+
+rpc.addEventListener('close', event => {
+ m.redraw()
+})
+
+rpc.addEventListener('event', event => {
+ const handler = rpcEventHandlers.get(event.detail.event)
+ if (handler !== undefined) {
+ handler(event.detail)
+ if (bufferCurrent !== undefined ||
+ event.detail.event !== Relay.Event.BufferLine)
+ m.redraw()
+ }
+})
+
+rpcEventHandlers.set(Relay.Event.Ping, e => {
+ rpc.send({command: 'PingResponse', eventSeq: e.eventSeq})
+})
+
+// ~~~ Buffer events ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+rpcEventHandlers.set(Relay.Event.BufferLine, e => {
+ let b = buffers.get(e.bufferName), line = {...e}
+ delete line.event
+ delete line.eventSeq
+ delete line.leakToActive
+ if (b === undefined)
+ return
+
+ // Initial sync: skip all other processing, let highlights be.
+ if (bufferCurrent === undefined) {
+ b.lines.push(line)
+ return
+ }
+
+ let visible = document.visibilityState !== 'hidden' &&
+ bufferLog === undefined &&
+ bufferAutoscroll &&
+ (e.bufferName == bufferCurrent || e.leakToActive)
+ b.lines.push({...line})
+ if (!(visible || e.leakToActive) ||
+ b.newMessages || b.newUnimportantMessages) {
+ if (line.isUnimportant)
+ b.newUnimportantMessages++
+ else
+ b.newMessages++
+ }
+
+ if (e.leakToActive) {
+ let bc = buffers.get(bufferCurrent)
+ bc.lines.push({...line, leaked: true})
+ if (!visible || bc.newMessages || bc.newUnimportantMessages) {
+ if (line.isUnimportant)
+ bc.newUnimportantMessages++
+ else
+ bc.newMessages++
+ }
+ }
+
+ if (line.isHighlight || (!visible && !line.isUnimportant &&
+ b.kind === Relay.BufferKind.PrivateMessage)) {
+ beep()
+ if (!visible)
+ b.highlighted = true
+ }
+})
+
+rpcEventHandlers.set(Relay.Event.BufferUpdate, e => {
+ let b = buffers.get(e.bufferName)
+ if (b === undefined) {
+ buffers.set(e.bufferName, (b = {
+ lines: [],
+ history: [],
+ historyAt: 0,
+ }))
+ bufferResetStats(b)
+ }
+
+ b.hideUnimportant = e.hideUnimportant
+ b.kind = e.context.kind
+ b.server = servers.get(e.context.serverName)
+ b.topic = e.context.topic
+ b.modes = e.context.modes
+})
+
+rpcEventHandlers.set(Relay.Event.BufferStats, e => {
+ let b = buffers.get(e.bufferName)
+ if (b === undefined)
+ return
+
+ b.newMessages = e.newMessages,
+ b.newUnimportantMessages = e.newUnimportantMessages
+ b.highlighted = e.highlighted
+})
+
+rpcEventHandlers.set(Relay.Event.BufferRename, e => {
+ buffers.set(e.new, buffers.get(e.bufferName))
+ buffers.delete(e.bufferName)
+})
+
+rpcEventHandlers.set(Relay.Event.BufferRemove, e => {
+ buffers.delete(e.bufferName)
+ if (e.bufferName === bufferLast)
+ bufferLast = undefined
+})
+
+rpcEventHandlers.set(Relay.Event.BufferActivate, e => {
+ let old = buffers.get(bufferCurrent)
+ if (old !== undefined)
+ bufferResetStats(old)
+
+ bufferLast = bufferCurrent
+ let b = buffers.get(e.bufferName)
+ bufferCurrent = e.bufferName
+ bufferLog = undefined
+ bufferAutoscroll = true
+ if (b !== undefined && document.visibilityState !== 'hidden')
+ b.highlighted = false
+
+ let textarea = document.getElementById('input')
+ if (textarea === null)
+ return
+
+ textarea.focus()
+ if (old !== undefined) {
+ old.input = textarea.value
+ old.inputStart = textarea.selectionStart
+ old.inputEnd = textarea.selectionEnd
+ old.inputDirection = textarea.selectionDirection
+ // Note that we effectively overwrite the newest line
+ // with the current textarea contents, and jump there.
+ old.historyAt = old.history.length
+ }
+
+ textarea.value = ''
+ if (b !== undefined && b.input !== undefined) {
+ textarea.value = b.input
+ textarea.setSelectionRange(b.inputStart, b.inputEnd, b.inputDirection)
+ }
+})
+
+rpcEventHandlers.set(Relay.Event.BufferClear, e => {
+ let b = buffers.get(e.bufferName)
+ if (b !== undefined)
+ b.lines.length = 0
+})
+
+// ~~~ Server events ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+rpcEventHandlers.set(Relay.Event.ServerUpdate, e => {
+ let s = servers.get(e.serverName)
+ if (s === undefined)
+ servers.set(e.serverName, (s = {}))
+ s.data = e.data
+})
+
+rpcEventHandlers.set(Relay.Event.ServerRename, e => {
+ servers.set(e.new, servers.get(e.serverName))
+ servers.delete(e.serverName)
+})
+
+rpcEventHandlers.set(Relay.Event.ServerRemove, e => {
+ servers.delete(e.serverName)
+})
+
+// --- Colours -----------------------------------------------------------------
+
+let palette = [
+ '#000', '#800', '#080', '#880', '#008', '#808', '#088', '#ccc',
+ '#888', '#f00', '#0f0', '#ff0', '#00f', '#f0f', '#0ff', '#fff',
+]
+palette.length = 256
+for (let i = 0; i < 216; i++) {
+ let r = i / 36 >> 0, g = (i / 6 >> 0) % 6, b = i % 6
+ r = !r ? '00' : (55 + 40 * r).toString(16)
+ g = !g ? '00' : (55 + 40 * g).toString(16)
+ b = !b ? '00' : (55 + 40 * b).toString(16)
+ palette[16 + i] = `#${r}${g}${b}`
+}
+for (let i = 0; i < 24; i++) {
+ let g = ('0' + (8 + i * 10).toString(16)).slice(-2)
+ palette[232 + i] = `#${g}${g}${g}`
+}
+
+// ---- UI ---------------------------------------------------------------------
+
+let linkRE = [
+ /https?:\/\//,
+ /([^\[\](){}<>"'\s]|\([^\[\](){}<>"'\s]*\))+/,
+ /([^\[\](){}<>"'\s,.:]|\([^\[\](){}<>"'\s]*\))/,
+].map(r => r.source).join('')
+
+let BufferList = {
+ view: vnode => {
+ let highlighted = false
+ let items = Array.from(buffers, ([name, b]) => {
+ let classes = [], displayName = name
+ if (name == bufferCurrent) {
+ classes.push('current')
+ } else if (b.newMessages) {
+ classes.push('activity')
+ displayName += ` (${b.newMessages})`
+ }
+ if (b.highlighted) {
+ classes.push('highlighted')
+ highlighted = true
+ }
+ return m('.item', {
+ onclick: event => bufferActivate(name),
+ class: classes.join(' '),
+ }, displayName)
+ })
+
+ updateIcon(rpc.ws === undefined ? null : highlighted)
+ return m('.list', {}, items)
+ },
+}
+
+let Content = {
+ applyColor: (fg, bg, inverse) => {
+ if (inverse)
+ [fg, bg] = [bg >= 0 ? bg : 15, fg >= 0 ? fg : 0]
+
+ let style = {}
+ if (fg >= 0)
+ style.color = palette[fg]
+ if (bg >= 0)
+ style.backgroundColor = palette[bg]
+ for (const _ in style)
+ return style
+ },
+
+ linkify: (text, attrs) => {
+ let re = new RegExp(linkRE, 'g'), a = [], end = 0, match
+ while ((match = re.exec(text)) !== null) {
+ if (end < match.index)
+ a.push(m('span', attrs, text.substring(end, match.index)))
+ a.push(m('a[target=_blank]', {href: match[0], ...attrs}, match[0]))
+ end = re.lastIndex
+ }
+ if (end < text.length)
+ a.push(m('span', attrs, text.substring(end)))
+ return a
+ },
+
+ makeMark: line => {
+ switch (line.rendition) {
+ case Relay.Rendition.Indent: return m('span.mark', {}, '')
+ case Relay.Rendition.Status: return m('span.mark', {}, '–')
+ case Relay.Rendition.Error: return m('span.mark.error', {}, '⚠')
+ case Relay.Rendition.Join: return m('span.mark.join', {}, '→')
+ case Relay.Rendition.Part: return m('span.mark.part', {}, '←')
+ case Relay.Rendition.Action: return m('span.mark.action', {}, '✶')
+ }
+ },
+
+ view: vnode => {
+ let line = vnode.children[0]
+ let classes = new Set()
+ let flip = c => {
+ if (classes.has(c))
+ classes.delete(c)
+ else
+ classes.add(c)
+ }
+
+ let fg = -1, bg = -1, inverse = false
+ return m('.content', vnode.attrs, [
+ Content.makeMark(line),
+ line.items.flatMap(item => {
+ switch (item.kind) {
+ case Relay.Item.Text:
+ return Content.linkify(item.text, {
+ class: Array.from(classes.keys()).join(' '),
+ style: Content.applyColor(fg, bg, inverse),
+ })
+ case Relay.Item.Reset:
+ classes.clear()
+ fg = bg = -1
+ inverse = false
+ break
+ case Relay.Item.FgColor:
+ fg = item.color
+ break
+ case Relay.Item.BgColor:
+ bg = item.color
+ break
+ case Relay.Item.FlipInverse:
+ inverse = !inverse
+ break
+ case Relay.Item.FlipBold:
+ flip('b')
+ break
+ case Relay.Item.FlipItalic:
+ flip('i')
+ break
+ case Relay.Item.FlipUnderline:
+ flip('u')
+ break
+ case Relay.Item.FlipCrossedOut:
+ flip('s')
+ break
+ case Relay.Item.FlipMonospace:
+ flip('m')
+ break
+ }
+ }),
+ ])
+ },
+}
+
+let Topic = {
+ view: vnode => {
+ let b = buffers.get(bufferCurrent)
+ if (b !== undefined && b.topic !== undefined)
+ return m(Content, {}, {items: b.topic})
+ },
+}
+
+let Buffer = {
+ controller: new AbortController(),
+
+ onbeforeremove: vnode => {
+ Buffer.controller.abort()
+ },
+
+ onupdate: vnode => {
+ if (bufferAutoscroll)
+ vnode.dom.scrollTop = vnode.dom.scrollHeight
+ },
+
+ oncreate: vnode => {
+ Buffer.onupdate(vnode)
+ window.addEventListener('resize', event => Buffer.onupdate(vnode),
+ {signal: Buffer.controller.signal})
+ },
+
+ view: vnode => {
+ let lines = []
+ let b = buffers.get(bufferCurrent)
+ if (b === undefined)
+ return m('.buffer')
+
+ let lastDateMark = undefined
+ let squashing = false
+ let markBefore = b.lines.length
+ - b.newMessages - b.newUnimportantMessages
+ b.lines.forEach((line, i) => {
+ if (i == markBefore)
+ lines.push(m('.unread'))
+
+ if (!line.isUnimportant || !b.hideUnimportant) {
+ squashing = false
+ } else if (squashing) {
+ return
+ } else {
+ squashing = true
+ }
+
+ let date = new Date(line.when)
+ let dateMark = date.toLocaleDateString()
+ if (dateMark !== lastDateMark) {
+ lines.push(m('.date', {}, dateMark))
+ lastDateMark = dateMark
+ }
+ if (squashing) {
+ lines.push(m('.time.hidden'))
+ lines.push(m('.content'))
+ return
+ }
+
+ let attrs = {}
+ if (line.leaked)
+ attrs.class = 'leaked'
+
+ lines.push(m('.time', {...attrs}, date.toLocaleTimeString()))
+ lines.push(m(Content, {...attrs}, line))
+ })
+
+ let dateMark = new Date().toLocaleDateString()
+ if (dateMark !== lastDateMark && lastDateMark !== undefined)
+ lines.push(m('.date', {}, dateMark))
+ return m('.buffer', {onscroll: event => {
+ const dom = event.target
+ bufferAutoscroll =
+ dom.scrollTop + dom.clientHeight + 1 >= dom.scrollHeight
+ }}, lines)
+ },
+}
+
+let Log = {
+ oncreate: vnode => {
+ vnode.dom.scrollTop = vnode.dom.scrollHeight
+ vnode.dom.focus()
+ },
+
+ linkify: text => {
+ let re = new RegExp(linkRE, 'g'), a = [], end = 0, match
+ while ((match = re.exec(text)) !== null) {
+ if (end < match.index)
+ a.push(text.substring(end, match.index))
+ a.push(m('a[target=_blank]', {href: match[0]}, match[0]))
+ end = re.lastIndex
+ }
+ if (end < text.length)
+ a.push(text.substring(end))
+ return a
+ },
+
+ view: vnode => {
+ return m(".log", {}, Log.linkify(bufferLog))
+ },
+}
+
+let Completions = {
+ entries: [],
+
+ reset: list => {
+ Completions.entries = list || []
+ m.redraw()
+ },
+
+ view: vnode => {
+ if (!Completions.entries.length)
+ return
+ return m('.completions', {},
+ Completions.entries.map(option => m('.completion', {}, option)))
+ },
+}
+
+let BufferContainer = {
+ view: vnode => {
+ return m('.buffer-container', {}, [
+ m('.filler'),
+ bufferLog !== undefined ? m(Log) : m(Buffer),
+ m(Completions),
+ ])
+ },
+}
+
+let Toolbar = {
+ format: formatting => {
+ let textarea = document.getElementById('input')
+ if (textarea !== null)
+ Input.format(textarea, formatting)
+ },
+
+ view: vnode => {
+ let indicators = []
+ if (bufferLog === undefined && !bufferAutoscroll)
+ indicators.push(m('.indicator', {}, '⇩'))
+ if (Input.formatting)
+ indicators.push(m('.indicator', {}, '#'))
+ return m('.toolbar', {}, [
+ indicators,
+ m('button', {onclick: event => Toolbar.format('\u0002')},
+ m('b', {}, 'B')),
+ m('button', {onclick: event => Toolbar.format('\u001D')},
+ m('i', {}, 'I')),
+ m('button', {onclick: event => Toolbar.format('\u001F')},
+ m('u', {}, 'U')),
+ m('button', {onclick: event => bufferToggleLog()},
+ bufferLog === undefined ? 'Log' : 'Hide log'),
+ ])
+ },
+}
+
+let Status = {
+ view: vnode => {
+ let b = buffers.get(bufferCurrent)
+ if (b === undefined)
+ return m('.status', {}, 'Synchronizing...')
+
+ let status = `${bufferCurrent}`
+ if (b.modes)
+ status += `(+${b.modes})`
+ if (b.hideUnimportant)
+ status += `<H>`
+ return m('.status', {}, [status, m(Toolbar)])
+ },
+}
+
+let Prompt = {
+ view: vnode => {
+ let b = buffers.get(bufferCurrent)
+ if (b === undefined || b.server === undefined)
+ return
+
+ if (b.server.data.user !== undefined) {
+ let user = b.server.data.user
+ if (b.server.data.userModes)
+ user += `(${b.server.data.userModes})`
+ return m('.prompt', {}, `${user}`)
+ }
+
+ // This might certainly be done more systematically.
+ let state = b.server.data.state
+ for (const s in Relay.ServerState)
+ if (Relay.ServerState[s] == state) {
+ state = s
+ break
+ }
+ return m('.prompt', {}, `(${state})`)
+ },
+}
+
+let Input = {
+ counter: 0,
+ stamp: textarea => {
+ return [Input.counter,
+ textarea.selectionStart, textarea.selectionEnd, textarea.value]
+ },
+
+ complete: (b, textarea) => {
+ if (textarea.selectionStart !== textarea.selectionEnd)
+ return false
+
+ // Cancel any previous autocomplete, and ensure applicability.
+ Input.counter++
+ let state = Input.stamp(textarea)
+ rpc.send({
+ command: 'BufferComplete',
+ bufferName: bufferCurrent,
+ text: textarea.value,
+ position: utf8Encode(
+ textarea.value.slice(0, textarea.selectionEnd)).length,
+ }).then(resp => {
+ if (!Input.stamp(textarea).every((v, k) => v === state[k]))
+ return
+
+ let preceding = utf8Encode(textarea.value).slice(0, resp.start)
+ let start = utf8Decode(preceding).length
+ if (resp.completions.length > 0) {
+ textarea.setRangeText(resp.completions[0],
+ start, textarea.selectionEnd, 'end')
+ }
+
+ if (resp.completions.length == 1) {
+ textarea.setRangeText(' ',
+ textarea.selectionStart, textarea.selectionEnd, 'end')
+ } else {
+ beep()
+ }
+
+ if (resp.completions.length > 1)
+ Completions.reset(resp.completions.slice(1))
+ })
+ return true
+ },
+
+ submit: (b, textarea) => {
+ rpc.send({
+ command: 'BufferInput',
+ bufferName: bufferCurrent,
+ text: textarea.value,
+ })
+
+ // b.history[b.history.length] is virtual, and is represented
+ // either by textarea contents when it's currently being edited,
+ // or by b.input in all other cases.
+ b.history.push(textarea.value)
+ b.historyAt = b.history.length
+ textarea.value = ''
+ return true
+ },
+
+ backward: textarea => {
+ if (textarea.selectionStart !== textarea.selectionEnd)
+ return false
+
+ let point = textarea.selectionStart
+ if (point < 1)
+ return false
+ while (point && /\s/.test(textarea.value.charAt(--point))) {}
+ while (point-- && !/\s/.test(textarea.value.charAt(point))) {}
+ point++
+ textarea.setSelectionRange(point, point)
+ return true
+ },
+
+ forward: textarea => {
+ if (textarea.selectionStart !== textarea.selectionEnd)
+ return false
+
+ let point = textarea.selectionStart, len = textarea.value.length
+ if (point + 1 > len)
+ return false
+ while (point < len && /\s/.test(textarea.value.charAt(point))) point++
+ while (point < len && !/\s/.test(textarea.value.charAt(point))) point++
+ textarea.setSelectionRange(point, point)
+ return true
+ },
+
+ modifyWord: (textarea, cb) => {
+ let start = textarea.selectionStart
+ let end = textarea.selectionEnd
+ if (start === end) {
+ let len = textarea.value.length
+ while (start < len && /\s/.test(textarea.value.charAt(start)))
+ start++;
+ end = start
+ while (end < len && !/\s/.test(textarea.value.charAt(end)))
+ end++;
+ }
+ if (start === end)
+ return false
+
+ const text = textarea.value, modified = cb(text.substring(start, end))
+ textarea.value = text.slice(0, start) + modified + text.slice(end)
+ end = start + modified.length
+ textarea.setSelectionRange(end, end)
+ return true
+ },
+
+ downcase: textarea => {
+ return Input.modifyWord(textarea, text => text.toLowerCase())
+ },
+
+ upcase: textarea => {
+ return Input.modifyWord(textarea, text => text.toUpperCase())
+ },
+
+ capitalize: textarea => {
+ return Input.modifyWord(textarea, text => {
+ const cps = Array.from(text.toLowerCase())
+ return cps[0].toUpperCase() + cps.slice(1).join('')
+ })
+ },
+
+ first: (b, textarea) => {
+ if (b.historyAt <= 0)
+ return false
+
+ if (b.historyAt == b.history.length)
+ b.input = textarea.value
+ textarea.value = b.history[(b.historyAt = 0)]
+ return true
+ },
+
+ last: (b, textarea) => {
+ if (b.historyAt >= b.history.length)
+ return false
+
+ b.historyAt = b.history.length
+ textarea.value = b.input
+ return true
+ },
+
+ previous: (b, textarea) => {
+ if (b.historyAt <= 0)
+ return false
+
+ if (b.historyAt == b.history.length)
+ b.input = textarea.value
+ textarea.value = b.history[--b.historyAt]
+ return true
+ },
+
+ next: (b, textarea) => {
+ if (b.historyAt >= b.history.length)
+ return false
+
+ if (++b.historyAt == b.history.length)
+ textarea.value = b.input
+ else
+ textarea.value = b.history[b.historyAt]
+ return true
+ },
+
+ formatting: false,
+
+ format: (textarea, formatting) => {
+ const [start, end] = [textarea.selectionStart, textarea.selectionEnd]
+ if (start === end) {
+ textarea.setRangeText(formatting)
+ textarea.setSelectionRange(
+ start + formatting.length, end + formatting.length)
+ } else {
+ textarea.setRangeText(
+ formatting + textarea.value.substr(start, end) + formatting)
+ }
+ textarea.focus()
+ },
+
+ onKeyDown: event => {
+ // TODO: And perhaps on other actions, too.
+ rpc.send({command: 'Active'})
+
+ let b = buffers.get(bufferCurrent)
+ if (b === undefined)
+ return
+
+ let textarea = event.currentTarget
+ let handled = false
+ let success = true
+ if (Input.formatting) {
+ Input.formatting = false
+
+ // Like process_formatting_escape() within xC.
+ handled = true
+ switch (event.key) {
+ case 'b': Input.format(textarea, '\u0002'); break
+ case 'c': Input.format(textarea, '\u0003'); break
+ case 'q':
+ case 'm': Input.format(textarea, '\u0011'); break
+ case 'v': Input.format(textarea, '\u0016'); break
+ case 'i':
+ case ']': Input.format(textarea, '\u001D'); break
+ case 's':
+ case 'x':
+ case '^': Input.format(textarea, '\u001E'); break
+ case 'u':
+ case '_': Input.format(textarea, '\u001F'); break
+ case 'r':
+ case 'o': Input.format(textarea, '\u000F'); break
+ default: success = false
+ }
+ } else if (hasShortcutModifiers(event)) {
+ handled = true
+ switch (event.key) {
+ case 'b': success = Input.backward(textarea); break
+ case 'f': success = Input.forward(textarea); break
+ case 'l': success = Input.downcase(textarea); break
+ case 'u': success = Input.upcase(textarea); break
+ case 'c': success = Input.capitalize(textarea); break
+ case '<': success = Input.first(b, textarea); break
+ case '>': success = Input.last(b, textarea); break
+ case 'p': success = Input.previous(b, textarea); break
+ case 'n': success = Input.next(b, textarea); break
+ case 'm': success = Input.formatting = true; break
+ default: handled = false
+ }
+ } else if (!event.altKey && !event.ctrlKey && !event.metaKey &&
+ !event.shiftKey) {
+ handled = true
+ switch (event.keyCode) {
+ case 9: success = Input.complete(b, textarea); break
+ case 13: success = Input.submit(b, textarea); break
+ default: handled = false
+ }
+ }
+ if (!success)
+ beep()
+ if (handled)
+ event.preventDefault()
+ },
+
+ onStateChange: event => {
+ Completions.reset()
+ Input.formatting = false
+ },
+
+ view: vnode => {
+ return m('textarea#input', {
+ rows: 1,
+ onkeydown: Input.onKeyDown,
+ oninput: Input.onStateChange,
+ // Sadly only supported in Firefox as of writing.
+ onselectionchange: Input.onStateChange,
+ // The list of completions is scrollable without receiving focus.
+ onblur: Input.onStateChange,
+ })
+ },
+}
+
+let Main = {
+ view: vnode => {
+ let overlay = undefined
+ if (connecting)
+ overlay = m('.overlay', {}, "Connecting...")
+ else if (rpc.ws === undefined)
+ overlay = m('.overlay', {}, [
+ m('', {}, "Disconnected"),
+ m('', {}, m('small', {}, "Reload page to reconnect.")),
+ ])
+
+ return m('.xP', {}, [
+ overlay,
+ m('.title', {}, [m('b', {}, `xP`), m(Topic)]),
+ m('.middle', {}, [m(BufferList), m(BufferContainer)]),
+ m(Status),
+ m('.input', {}, [m(Prompt), m(Input)]),
+ ])
+ },
+}
+
+window.addEventListener('load', () => m.mount(document.body, Main))
+
+document.addEventListener('visibilitychange', event => {
+ let b = buffers.get(bufferCurrent)
+ if (b !== undefined && document.visibilityState !== 'hidden') {
+ b.highlighted = false
+ m.redraw()
+ }
+})
+
+// On macOS, the Alt/Option key transforms characters, which basically breaks
+// all event.altKey shortcuts, so implement Escape prefixing on that system.
+// This method of detection only works with Blink browsers, as of writing.
+let lastWasEscape = false
+document.addEventListener('keydown', event => {
+ event.escapePrefix = lastWasEscape
+ if (lastWasEscape) {
+ lastWasEscape = false
+ } else if (event.code == 'Escape' &&
+ navigator.userAgentData?.platform === 'macOS') {
+ event.preventDefault()
+ event.stopPropagation()
+ lastWasEscape = true
+ return
+ }
+
+ if (rpc.ws == undefined || !hasShortcutModifiers(event))
+ return
+
+ // Rotate names so that the current buffer comes first.
+ let names = [...buffers.keys()]
+ names.push.apply(names,
+ names.splice(0, names.findIndex(name => name == bufferCurrent)))
+
+ switch (event.key) {
+ case 'h':
+ bufferToggleLog()
+ break
+ case 'H':
+ if (bufferCurrent !== undefined)
+ bufferToggleUnimportant(bufferCurrent)
+ break
+ case 'a':
+ for (const name of names.slice(1))
+ if (buffers.get(name).newMessages) {
+ bufferActivate(name)
+ break
+ }
+ break
+ case '!':
+ for (const name of names.slice(1))
+ if (buffers.get(name).highlighted) {
+ bufferActivate(name)
+ break
+ }
+ break
+ case 'Tab':
+ if (bufferLast !== undefined)
+ bufferActivate(bufferLast)
+ break
+ case 'PageUp':
+ if (names.length > 1)
+ bufferActivate(names.at(-1))
+ break
+ case 'PageDown':
+ if (names.length > 1)
+ bufferActivate(names.at(+1))
+ break
+ default:
+ return
+ }
+
+ event.preventDefault()
+}, true)
diff --git a/xP/xP.go b/xP/xP.go
new file mode 100644
index 0000000..20117b2
--- /dev/null
+++ b/xP/xP.go
@@ -0,0 +1,299 @@
+// Copyright (c) 2022, Přemysl Eric Janouch <p@janouch.name>
+// SPDX-License-Identifier: 0BSD
+
+package main
+
+import (
+ "bufio"
+ "context"
+ "encoding/binary"
+ "encoding/json"
+ "flag"
+ "fmt"
+ "html/template"
+ "io"
+ "log"
+ "net"
+ "net/http"
+ "os"
+ "time"
+
+ "nhooyr.io/websocket"
+)
+
+var (
+ debug = flag.Bool("debug", false, "enable debug output")
+
+ addressBind string
+ addressConnect string
+ addressWS string
+)
+
+// -----------------------------------------------------------------------------
+
+func relayReadFrame(r io.Reader) []byte {
+ var length uint32
+ if err := binary.Read(r, binary.BigEndian, &length); err != nil {
+ log.Println("Event receive failed: " + err.Error())
+ return nil
+ }
+ b := make([]byte, length)
+ if _, err := io.ReadFull(r, b); err != nil {
+ log.Println("Event receive failed: " + err.Error())
+ return nil
+ }
+
+ if *debug {
+ log.Printf("<? %v\n", b)
+
+ var m RelayEventMessage
+ if after, ok := m.ConsumeFrom(b); !ok {
+ log.Println("Event deserialization failed")
+ return nil
+ } else if len(after) != 0 {
+ log.Println("Event deserialization failed: trailing data")
+ return nil
+ }
+
+ j, err := m.MarshalJSON()
+ if err != nil {
+ log.Println("Event marshalling failed: " + err.Error())
+ return nil
+ }
+
+ log.Printf("<- %s\n", j)
+ }
+ return b
+}
+
+func relayMakeReceiver(ctx context.Context, conn net.Conn) <-chan []byte {
+ // The usual event message rarely gets above 1 kilobyte,
+ // thus this is set to buffer up at most 1 megabyte or so.
+ p := make(chan []byte, 1000)
+ r := bufio.NewReaderSize(conn, 65536)
+ go func() {
+ defer close(p)
+ for {
+ j := relayReadFrame(r)
+ if j == nil {
+ return
+ }
+ select {
+ case p <- j:
+ case <-ctx.Done():
+ return
+ }
+ }
+ }()
+ return p
+}
+
+func relayWriteJSON(conn net.Conn, j []byte) bool {
+ var m RelayCommandMessage
+ if err := json.Unmarshal(j, &m); err != nil {
+ log.Println("Command unmarshalling failed: " + err.Error())
+ return false
+ }
+
+ b, ok := m.AppendTo(make([]byte, 4))
+ if !ok {
+ log.Println("Command serialization failed")
+ return false
+ }
+ binary.BigEndian.PutUint32(b[:4], uint32(len(b)-4))
+ if _, err := conn.Write(b); err != nil {
+ log.Println("Command send failed: " + err.Error())
+ return false
+ }
+
+ if *debug {
+ log.Printf("-> %v\n", b)
+ }
+ return true
+}
+
+// -----------------------------------------------------------------------------
+
+func clientReadJSON(ctx context.Context, ws *websocket.Conn) []byte {
+ t, j, err := ws.Read(ctx)
+ if err != nil {
+ log.Println("Command receive failed: " + err.Error())
+ return nil
+ }
+ if t != websocket.MessageText {
+ log.Println(
+ "Command receive failed: " + "binary messages are not supported")
+ return nil
+ }
+
+ if *debug {
+ log.Printf("?> %s\n", j)
+ }
+ return j
+}
+
+func clientWriteBinary(ctx context.Context, ws *websocket.Conn, b []byte) bool {
+ if err := ws.Write(ctx, websocket.MessageBinary, b); err != nil {
+ log.Println("Event send failed: " + err.Error())
+ return false
+ }
+ return true
+}
+
+func clientWriteError(ctx context.Context, ws *websocket.Conn, err error) bool {
+ b, ok := (&RelayEventMessage{
+ EventSeq: 0,
+ Data: RelayEventData{
+ Interface: RelayEventDataError{
+ Event: RelayEventError,
+ CommandSeq: 0,
+ Error: err.Error(),
+ },
+ },
+ }).AppendTo(nil)
+ if ok {
+ log.Println("Event serialization failed")
+ return false
+ }
+ return clientWriteBinary(ctx, ws, b)
+}
+
+func handleWS(w http.ResponseWriter, r *http.Request) {
+ ws, err := websocket.Accept(w, r, &websocket.AcceptOptions{
+ InsecureSkipVerify: true,
+ // Note that Safari can be broken with compression.
+ CompressionMode: websocket.CompressionContextTakeover,
+ // This is for the payload; set higher to avoid overhead.
+ CompressionThreshold: 64 << 10,
+ })
+ if err != nil {
+ log.Println("Client rejected: " + err.Error())
+ return
+ }
+ defer ws.Close(websocket.StatusGoingAway, "Goodbye")
+
+ ctx, cancel := context.WithCancel(r.Context())
+ defer cancel()
+
+ conn, err := net.Dial("tcp", addressConnect)
+ if err != nil {
+ log.Println("Connection failed: " + err.Error())
+ clientWriteError(ctx, ws, err)
+ return
+ }
+ defer conn.Close()
+
+ // To decrease latencies, events are received and decoded in parallel
+ // to their sending, and we try to batch them together.
+ relayFrames := relayMakeReceiver(ctx, conn)
+ batchFrames := func() []byte {
+ batch, ok := <-relayFrames
+ if !ok {
+ return nil
+ }
+ Batch:
+ for {
+ select {
+ case b, ok := <-relayFrames:
+ if !ok {
+ break Batch
+ }
+ batch = append(batch, b...)
+ default:
+ break Batch
+ }
+ }
+ return batch
+ }
+
+ // We don't need to intervene, so it's just two separate pipes so far.
+ go func() {
+ defer cancel()
+ for {
+ j := clientReadJSON(ctx, ws)
+ if j == nil {
+ return
+ }
+ relayWriteJSON(conn, j)
+ }
+ }()
+ go func() {
+ defer cancel()
+ for {
+ b := batchFrames()
+ if b == nil {
+ return
+ }
+ clientWriteBinary(ctx, ws, b)
+ }
+ }()
+ <-ctx.Done()
+}
+
+// -----------------------------------------------------------------------------
+
+var staticHandler = http.FileServer(http.Dir("."))
+
+var page = template.Must(template.New("/").Parse(`<!DOCTYPE html>
+<html>
+<head>
+ <title>xP</title>
+ <meta charset="utf-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <link rel="stylesheet" href="xP.css" />
+</head>
+<body>
+ <script src="mithril.js">
+ </script>
+ <script>
+ let proxy = '{{ . }}'
+ </script>
+ <script type="module" src="xP.js">
+ </script>
+</body>
+</html>`))
+
+func handleDefault(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/" {
+ staticHandler.ServeHTTP(w, r)
+ return
+ }
+
+ wsURI := addressWS
+ if wsURI == "" {
+ wsURI = fmt.Sprintf("ws://%s/ws", r.Host)
+ }
+ if err := page.Execute(w, wsURI); err != nil {
+ log.Println("Template execution failed: " + err.Error())
+ }
+}
+
+func main() {
+ flag.Usage = func() {
+ fmt.Fprintf(flag.CommandLine.Output(),
+ "Usage: %s [OPTION...] BIND CONNECT [WSURI]\n\n", os.Args[0])
+ flag.PrintDefaults()
+ }
+
+ flag.Parse()
+ if flag.NArg() < 2 || flag.NArg() > 3 {
+ flag.Usage()
+ os.Exit(1)
+ }
+
+ addressBind, addressConnect = flag.Arg(0), flag.Arg(1)
+ if flag.NArg() > 2 {
+ addressWS = flag.Arg(2)
+ }
+
+ http.Handle("/ws", http.HandlerFunc(handleWS))
+ http.Handle("/", http.HandlerFunc(handleDefault))
+
+ s := &http.Server{
+ Addr: addressBind,
+ ReadTimeout: 60 * time.Second,
+ WriteTimeout: 60 * time.Second,
+ MaxHeaderBytes: 32 << 10,
+ }
+ log.Fatal(s.ListenAndServe())
+}