aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--CMakeLists.txt5
-rw-r--r--LICENSE2
-rw-r--r--NEWS27
-rw-r--r--README.adoc25
m---------liberty0
-rwxr-xr-xtest52
-rw-r--r--test.lua72
-rw-r--r--xA/.gitignore1
-rw-r--r--xA/Makefile13
-rw-r--r--xA/go.mod49
-rw-r--r--xA/go.sum673
-rw-r--r--xA/xA.go774
-rw-r--r--xC.c840
-rw-r--r--xC.lxdr53
-rw-r--r--xK-version2
-rw-r--r--xM/gen-icon.swift3
-rw-r--r--xM/main.swift10
-rw-r--r--xN/xN.go10
-rw-r--r--xP/go.mod9
-rw-r--r--xP/go.sum64
-rw-r--r--xP/public/xP.js46
-rw-r--r--xR/.gitignore2
-rw-r--r--xR/Makefile17
-rw-r--r--xR/go.mod5
-rw-r--r--xR/xR.adoc41
-rw-r--r--xR/xR.go134
-rw-r--r--xS/Dockerfile10
-rw-r--r--xT/CMakeLists.txt179
-rw-r--r--xT/config.h.in7
-rw-r--r--xT/xT-highlighted.svg29
-rw-r--r--xT/xT.cpp1734
-rw-r--r--xT/xT.desktop8
-rw-r--r--xT/xT.svg29
-rw-r--r--xT/xTq.cpp40
-rw-r--r--xT/xTq.h15
-rw-r--r--xT/xTq.qml105
-rw-r--r--xW/xW.cpp191
37 files changed, 3822 insertions, 1454 deletions
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 9afcdf4..8492a00 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -212,6 +212,11 @@ if (BUILD_TESTING)
add_test (NAME custom-static-analysis
COMMAND ${PROJECT_SOURCE_DIR}/test-static)
endif ()
+option (BUILD_TESTING_WDYE "Build the integration test" OFF)
+if (BUILD_TESTING_WDYE)
+ add_subdirectory (liberty/tools/wdye)
+ add_test (NAME integration COMMAND wdye "${PROJECT_SOURCE_DIR}/test.lua")
+endif ()
# Various clang-based diagnostics, loads of fake positives and spam
file (GLOB clang_tidy_sources *.c)
diff --git a/LICENSE b/LICENSE
index d58be36..69c9c4c 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2014 - 2024, Přemysl Eric Janouch <p@janouch.name>
+Copyright (c) 2014 - 2025, Přemysl Eric Janouch <p@janouch.name>
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted.
diff --git a/NEWS b/NEWS
index 42b71a5..d9699ca 100644
--- a/NEWS
+++ b/NEWS
@@ -1,3 +1,30 @@
+Unreleased
+
+ * xC: added more characters as nickname delimiters,
+ so that @nick works as a highlight
+
+ * xC: prevented rare crashes in relay code
+
+ * xP: added a network lag indicator to the user interface
+
+ * Bumped relay protocol version
+
+
+2.1.0 (2024-12-19) "Bunnyrific"
+
+ * xC: fixed a crash when the channel topic had too many formatting items
+
+ * xC: fixed keyboard EOF behaviour with Readline >= 8.0
+
+ * xC: made it possible to stream commands into the binary
+
+ * xM/xW: various bugfixes
+
+ * Added a Fyne frontend for xC called xA
+
+ * Added a Qt Widgets frontend for xC called xT
+
+
2.0.0 (2024-07-28) "Perfect Is the Enemy of Good"
* xD: now using SHA-256 for client certificate fingerprints
diff --git a/README.adoc b/README.adoc
index 3db0db4..1866b2a 100644
--- a/README.adoc
+++ b/README.adoc
@@ -2,9 +2,9 @@ xK
==
'xK' (chat kit) is an IRC software suite consisting of a daemon, bot, notifier,
-terminal client, and web/Windows/macOS frontends for the client. It's all
-you're ever going to need for chatting, so long as you can make do with slightly
-minimalist software.
+terminal client, and web/Windows/macOS/Linux/FreeBSD/Android/iOS frontends
+for the client. It's all you're ever going to need for chatting, so long as
+you can make do with slightly minimalist software.
They're all lean on dependencies, and offer a maximally permissive licence.
@@ -33,9 +33,10 @@ including link:xC.adoc#_key_bindings[keyboard shortcuts].
image::xP.webp[align="center"]
-xF
---
-The X11 frontend for 'xC', still under development.
+xA, xT, xF, xW, xM
+------------------
+Fyne, Qt Widgets, X11, Win32, Cocoa frontends for 'xC'.
+Using them is not recommended.
xD
--
@@ -147,6 +148,18 @@ For remote use, it's recommended to put 'xP' behind a reverse proxy, with TLS,
and some form of HTTP authentication. Pass the external URL of the WebSocket
endpoint as the third command line argument in this case.
+xA
+~~
+The Fyne frontend supports all of Linux, FreeBSD, Windows, macOS, Android, and
+iOS natively, albeit somewhat poorly. Only use `fyne` or `fyne-cross` after
+running `make generate` first.
+
+xT
+~~
+The Qt Widgets frontend is a separate CMake subproject. It generally supports
+all desktop operating systems. To avoid having to specify the relay address
+each time you run it, pass it on the command line.
+
xW
~~
The Win32 frontend is a separate CMake subproject that should be compiled
diff --git a/liberty b/liberty
-Subproject 492815c8fc38ad6e333b2f1c5094a329e307615
+Subproject 31ae40085206dc365a15fd6e9d13978e392f8b3
diff --git a/test b/test
deleted file mode 100755
index e8c2b53..0000000
--- a/test
+++ /dev/null
@@ -1,52 +0,0 @@
-#!/usr/bin/expect -f
-# Very basic end-to-end testing for CI
-set tempdir [exec mktemp -d]
-set ::env(XDG_CONFIG_HOME) $tempdir
-
-# Run the daemon to test against
-system ./xD --write-default-cfg
-spawn ./xD -d
-
-# 10 seconds is a bit too much
-set timeout 5
-
-spawn ./xC
-
-# Fuck this Tcl shit, I want the exit code
-expect_after {
- eof {
- puts ""
- puts "Child exited prematurely"
- exit 1
- }
-}
-
-# Connect to the daemon
-send "/server add localhost\n"
-expect "]"
-send "/set servers.localhost.addresses = \"localhost\"\n"
-expect "Option changed"
-send "/disconnect\n"
-expect "]"
-send "/connect\n"
-expect "Welcome to"
-
-# Try some chatting
-send "/join #test\n"
-expect "has joined"
-send "Hello\n"
-expect "Hello"
-
-# Attributes
-send "\x1bmbBold text! \x1bmc0,5And colors.\n"
-expect "]"
-
-# Try basic commands
-send "/set\n"
-expect "]"
-send "/help\n"
-expect "]"
-
-# Quit
-send "/quit\n"
-expect "Shutting down"
diff --git a/test.lua b/test.lua
new file mode 100644
index 0000000..2edeca8
--- /dev/null
+++ b/test.lua
@@ -0,0 +1,72 @@
+#!/usr/bin/env wdye
+-- Very basic end-to-end testing for CI
+function exec (...)
+ local p = wdye.spawn(...)
+ local out = wdye.expect(p:eof {function (p) return p[0] end})
+ if not out then
+ error "exec() timeout"
+ end
+
+ local status = p:wait()
+ if status ~= 0 then
+ io.write(out, "\n")
+ error("exit status " .. status)
+ end
+ return out:gsub("%s+$", "")
+end
+
+local temp = exec {"mktemp", "-d"}
+local atexit = {}
+setmetatable(atexit, {__gc = function () exec {"rm", "-rf", "--", temp} end})
+
+local env = {XDG_CONFIG_HOME=temp, TERM="xterm"}
+exec {"./xD", "--write-default-cfg", environ=env}
+
+-- Run the daemon to test against (assuming the default port 6667)
+local xD = wdye.spawn {"./xD", "-d", environ=env}
+local xC = wdye.spawn {"./xC", environ=env}
+
+function send (...) xC:send(...) end
+function expect (string)
+ wdye.expect(xC:exact {string},
+ wdye.timeout {5, function (p) error "xC timeout" end},
+ xC:eof {function (p) error "xC exited prematurely" end})
+end
+
+-- Connect to the daemon
+send "/server add localhost\n"
+expect "]"
+send "/set servers.localhost.addresses = \"localhost\"\n"
+expect "Option changed"
+send "/disconnect\n"
+expect "]"
+send "/connect\n"
+expect "Welcome to"
+
+-- Try some chatting
+send "/join #test\n"
+expect "has joined"
+send "Hello\n"
+expect "Hello"
+
+-- Attributes
+send "\x1bmbBold text! \x1bmc0,5And colors.\n"
+expect "]"
+
+-- Try basic commands
+send "/set\n"
+expect "]"
+send "/help\n"
+expect "]"
+
+-- Quit
+send "/quit\n"
+expect "Shutting down"
+
+local s1 = xC:wait()
+assert(s1 == 0, "xC exited abnormally: " .. s1)
+
+-- Send SIGINT (^C)
+xD:send "\003"
+local s2 = xD:wait()
+assert(s2 == 0, "xD exited abnormally: " .. s2)
diff --git a/xA/.gitignore b/xA/.gitignore
index 34289f1..5e6a147 100644
--- a/xA/.gitignore
+++ b/xA/.gitignore
@@ -2,3 +2,4 @@
/proto.go
/FyneApp.toml
/*.png
+/beep.raw
diff --git a/xA/Makefile b/xA/Makefile
index b597652..d0f0449 100644
--- a/xA/Makefile
+++ b/xA/Makefile
@@ -4,8 +4,10 @@
AWK = env LC_ALL=C awk
tools = ../liberty/tools
-outputs = FyneApp.toml xA proto.go xA.png xA-highlighted.png
+generated = FyneApp.toml xA.png xA-highlighted.png beep.raw proto.go
+outputs = xA $(generated)
all: $(outputs)
+generate: $(generated)
FyneApp.toml: ../xK-version
printf "\
@@ -21,11 +23,14 @@ FyneApp.toml: ../xK-version
Categories = ['Network', 'Chat', 'IRCClient']\n" > $@
.svg.png:
rsvg-convert --output=$@ -- $<
-xA: xA.go proto.go ../xK-version xA.png xA-highlighted.png
- go build -ldflags "-X 'main.projectVersion=$$(cat ../xK-version)'" -o $@ \
- -gcflags=all="-N -l"
+beep.raw:
+ sox -Dr 44100 -c 1 -e signed-integer -b 16 -L -n $@ \
+ synth 0.1 0 25 triangle 800 vol 0.5 fade t 0 -0 0.005 pad 0 0.05
proto.go: $(tools)/lxdrgen.awk $(tools)/lxdrgen-go.awk ../xC.lxdr
$(AWK) -f $(tools)/lxdrgen.awk -f $(tools)/lxdrgen-go.awk \
-v PrefixCamel=Relay ../xC.lxdr > $@
+xA: xA.go ../xK-version $(generated)
+ go build -ldflags "-X 'main.projectVersion=$$(cat ../xK-version)'" -o $@ \
+ -gcflags=all="-N -l"
clean:
rm -f $(outputs)
diff --git a/xA/go.mod b/xA/go.mod
index 40238ea..49c25cc 100644
--- a/xA/go.mod
+++ b/xA/go.mod
@@ -1,37 +1,46 @@
module janouch.name/xK/xA
-go 1.23
+go 1.23.0
-require fyne.io/fyne/v2 v2.5.2
+toolchain go1.24.0
+
+require (
+ fyne.io/fyne/v2 v2.6.0
+ github.com/ebitengine/oto/v3 v3.3.3
+)
require (
fyne.io/systray v1.11.0 // indirect
- github.com/BurntSushi/toml v1.4.0 // indirect
+ github.com/BurntSushi/toml v1.5.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
+ github.com/ebitengine/purego v0.8.2 // indirect
github.com/fredbi/uri v1.1.0 // indirect
- github.com/fsnotify/fsnotify v1.8.0 // indirect
- github.com/fyne-io/gl-js v0.0.0-20230506162202-1fdaa286a934 // indirect
- github.com/fyne-io/glfw-js v0.0.0-20240101223322-6e1efdc71b7a // indirect
- github.com/fyne-io/image v0.0.0-20240417123036-dc0ee9e7c964 // indirect
+ github.com/fsnotify/fsnotify v1.9.0 // indirect
+ github.com/fyne-io/gl-js v0.1.0 // indirect
+ github.com/fyne-io/glfw-js v0.2.0 // indirect
+ github.com/fyne-io/image v0.1.1 // indirect
+ github.com/fyne-io/oksvg v0.1.0 // indirect
github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 // indirect
- github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a // indirect
+ github.com/go-gl/glfw/v3.3/glfw v0.0.0-20250301202403-da16c1255728 // indirect
github.com/go-text/render v0.2.0 // indirect
- github.com/go-text/typesetting v0.2.0 // indirect
+ github.com/go-text/typesetting v0.3.0 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
- github.com/gopherjs/gopherjs v1.17.2 // indirect
- github.com/jeandeaual/go-locale v0.0.0-20240223122105-ce5225dcaa49 // indirect
+ github.com/hack-pad/go-indexeddb v0.3.2 // indirect
+ github.com/hack-pad/safejs v0.1.1 // indirect
+ github.com/jeandeaual/go-locale v0.0.0-20250421151639-a9d6ed1b3d45 // indirect
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect
- github.com/nicksnyder/go-i18n/v2 v2.4.1 // indirect
+ github.com/kr/text v0.2.0 // indirect
+ github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
+ github.com/nicksnyder/go-i18n/v2 v2.6.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
- github.com/rymdport/portal v0.2.6 // indirect
+ github.com/rymdport/portal v0.4.1 // indirect
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect
- github.com/stretchr/testify v1.9.0 // indirect
- github.com/yuin/goldmark v1.7.8 // indirect
- golang.org/x/image v0.22.0 // indirect
- golang.org/x/mobile v0.0.0-20241108191957-fa514ef75a0f // indirect
- golang.org/x/net v0.31.0 // indirect
- golang.org/x/sys v0.27.0 // indirect
- golang.org/x/text v0.20.0 // indirect
+ github.com/stretchr/testify v1.10.0 // indirect
+ github.com/yuin/goldmark v1.7.10 // indirect
+ golang.org/x/image v0.26.0 // indirect
+ golang.org/x/net v0.39.0 // indirect
+ golang.org/x/sys v0.32.0 // indirect
+ golang.org/x/text v0.24.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
diff --git a/xA/go.sum b/xA/go.sum
index 6a6d9f8..3a6f7ce 100644
--- a/xA/go.sum
+++ b/xA/go.sum
@@ -1,655 +1,84 @@
-cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
-cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
-cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
-cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU=
-cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
-cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc=
-cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0=
-cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To=
-cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4=
-cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M=
-cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc=
-cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk=
-cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs=
-cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc=
-cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY=
-cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI=
-cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk=
-cloud.google.com/go v0.78.0/go.mod h1:QjdrLG0uq+YwhjoVOLsS1t7TW8fs36kLs4XO5R5ECHg=
-cloud.google.com/go v0.79.0/go.mod h1:3bzgcEeQlzbuEAYu4mrWhKqWjmpprinYgKJLgKHnbb8=
-cloud.google.com/go v0.81.0/go.mod h1:mk/AM35KwGk/Nm2YSeZbxXdrNK3KZOYHmLkOqC2V6E0=
-cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
-cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
-cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=
-cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg=
-cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc=
-cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ=
-cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
-cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
-cloud.google.com/go/firestore v1.1.0/go.mod h1:ulACoGHTpvq5r8rxGJ4ddJZBZqakUQqClKRT5SZwBmk=
-cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
-cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
-cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA=
-cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU=
-cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
-cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos=
-cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
-cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
-cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
-dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
-fyne.io/fyne/v2 v2.5.2 h1:eSyGTmSkv10yAdAeHpDet6u2KkKxOGFc14kQu81We7Q=
-fyne.io/fyne/v2 v2.5.2/go.mod h1:26gqPDvtaxHeyct+C0BBjuGd2zwAJlPkUGSBrb+d7Ug=
+fyne.io/fyne/v2 v2.6.0 h1:Rywo9yKYN4qvNuvkRuLF+zxhJYWbIFM+m4N4KV4p1pQ=
+fyne.io/fyne/v2 v2.6.0/go.mod h1:YZt7SksjvrSNJCwbWFV32WON3mE1Sr7L41D29qMZ/lU=
fyne.io/systray v1.11.0 h1:D9HISlxSkx+jHSniMBR6fCFOUjk1x/OOOJLa9lJYAKg=
fyne.io/systray v1.11.0/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
-github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
-github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0=
-github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
-github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
-github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
-github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o=
-github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY=
-github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
-github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
-github.com/bketelsen/crypt v0.0.4/go.mod h1:aI6NrJ0pMGgvZKL1iVgXLnfIFJtfV+bKCoqOes/6LfM=
-github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
-github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
-github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
-github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
-github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
-github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
-github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
-github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
-github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
-github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
-github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
-github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
+github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
+github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
-github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
-github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
-github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po=
-github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
-github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
-github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
-github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
+github.com/ebitengine/oto/v3 v3.3.3 h1:m6RV69OqoXYSWCDsHXN9rc07aDuDstGHtait7HXSM7g=
+github.com/ebitengine/oto/v3 v3.3.3/go.mod h1:MZeb/lwoC4DCOdiTIxYezrURTw7EvK/yF863+tmBI+U=
+github.com/ebitengine/purego v0.8.2 h1:jPPGWs2sZ1UgOSgD2bClL0MJIqu58nOmIcBuXr62z1I=
+github.com/ebitengine/purego v0.8.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/felixge/fgprof v0.9.3 h1:VvyZxILNuCiUCSXtPtYmmtGvb65nqXh2QFWc0Wpf2/g=
github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw=
github.com/fredbi/uri v1.1.0 h1:OqLpTXtyRg9ABReqvDGdJPqZUxs8cyBDOMXBbskCaB8=
github.com/fredbi/uri v1.1.0/go.mod h1:aYTUoAXBOq7BLfVJ8GnKmfcuURosB1xyHDIfWeC/iW4=
-github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
-github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
-github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
-github.com/fyne-io/gl-js v0.0.0-20230506162202-1fdaa286a934 h1:dZC5aKobSN07hf71oMivxUmAofFja5GrfPK2rBlttX4=
-github.com/fyne-io/gl-js v0.0.0-20230506162202-1fdaa286a934/go.mod h1:d4clgH0/GrRwWjRzJJQXxT/h1TyuNSfF/X64zb/3Ggg=
-github.com/fyne-io/glfw-js v0.0.0-20240101223322-6e1efdc71b7a h1:ybgRdYvAHTn93HW79bLiBiJwVL4jVeyGQRZMgImoeWs=
-github.com/fyne-io/glfw-js v0.0.0-20240101223322-6e1efdc71b7a/go.mod h1:gsGA2dotD4v0SR6PmPCYvS9JuOeMwAtmfvDE7mbYXMY=
-github.com/fyne-io/image v0.0.0-20240417123036-dc0ee9e7c964 h1:0pTELtjlVAVGSazfwRNcqTVzqmkWb1GsNozCmmZfdZA=
-github.com/fyne-io/image v0.0.0-20240417123036-dc0ee9e7c964/go.mod h1:J9Uunu842kOcTjzQj4Eq8XIDmF55szvT1PTS1cUb1UE=
-github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
-github.com/go-gl/gl v0.0.0-20211210172815-726fda9656d6/go.mod h1:9YTyiznxEY1fVinfM7RvRcjRHbw2xLBJ3AAGIT0I4Nw=
+github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
+github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
+github.com/fyne-io/gl-js v0.1.0 h1:8luJzNs0ntEAJo+8x8kfUOXujUlP8gB3QMOxO2mUdpM=
+github.com/fyne-io/gl-js v0.1.0/go.mod h1:ZcepK8vmOYLu96JoxbCKJy2ybr+g1pTnaBDdl7c3ajI=
+github.com/fyne-io/glfw-js v0.2.0 h1:8GUZtN2aCoTPNqgRDxK5+kn9OURINhBEBc7M4O1KrmM=
+github.com/fyne-io/glfw-js v0.2.0/go.mod h1:Ri6te7rdZtBgBpxLW19uBpp3Dl6K9K/bRaYdJ22G8Jk=
+github.com/fyne-io/image v0.1.1 h1:WH0z4H7qfvNUw5l4p3bC1q70sa5+YWVt6HCj7y4VNyA=
+github.com/fyne-io/image v0.1.1/go.mod h1:xrfYBh6yspc+KjkgdZU/ifUC9sPA5Iv7WYUBzQKK7JM=
+github.com/fyne-io/oksvg v0.1.0 h1:7EUKk3HV3Y2E+qypp3nWqMXD7mum0hCw2KEGhI1fnBw=
+github.com/fyne-io/oksvg v0.1.0/go.mod h1:dJ9oEkPiWhnTFNCmRgEze+YNprJF7YRbpjgpWS4kzoI=
github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 h1:5BVwOaUSBTlVZowGO6VZGw2H/zl9nrd3eCZfYV+NfQA=
github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71/go.mod h1:9YTyiznxEY1fVinfM7RvRcjRHbw2xLBJ3AAGIT0I4Nw=
-github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
-github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
-github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
-github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a h1:vxnBhFDDT+xzxf1jTJKMKZw3H0swfWk9RpWbBbDK5+0=
-github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
+github.com/go-gl/glfw/v3.3/glfw v0.0.0-20250301202403-da16c1255728 h1:RkGhqHxEVAvPM0/R+8g7XRwQnHatO0KAuVcwHo8q9W8=
+github.com/go-gl/glfw/v3.3/glfw v0.0.0-20250301202403-da16c1255728/go.mod h1:SyRD8YfuKk+ZXlDqYiqe1qMSqjNgtHzBTG810KUagMc=
github.com/go-text/render v0.2.0 h1:LBYoTmp5jYiJ4NPqDc2pz17MLmA3wHw1dZSVGcOdeAc=
github.com/go-text/render v0.2.0/go.mod h1:CkiqfukRGKJA5vZZISkjSYrcdtgKQWRa2HIzvwNN5SU=
-github.com/go-text/typesetting v0.2.0 h1:fbzsgbmk04KiWtE+c3ZD4W2nmCRzBqrqQOvYlwAOdho=
-github.com/go-text/typesetting v0.2.0/go.mod h1:2+owI/sxa73XA581LAzVuEBZ3WEEV2pXeDswCH/3i1I=
-github.com/go-text/typesetting-utils v0.0.0-20240317173224-1986cbe96c66 h1:GUrm65PQPlhFSKjLPGOZNPNxLCybjzjYBzjfoBGaDUY=
-github.com/go-text/typesetting-utils v0.0.0-20240317173224-1986cbe96c66/go.mod h1:DDxDdQEnB70R8owOx3LVpEFvpMK9eeH1o2r0yZhFI9o=
-github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
+github.com/go-text/typesetting v0.3.0 h1:OWCgYpp8njoxSRpwrdd1bQOxdjOXDj9Rqart9ML4iF4=
+github.com/go-text/typesetting v0.3.0/go.mod h1:qjZLkhRgOEYMhU9eHBr3AR4sfnGJvOXNLt8yRAySFuY=
+github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066 h1:qCuYC+94v2xrb1PoS4NIDe7DGYtLnU2wWiQe9a1B1c0=
+github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066/go.mod h1:DDxDdQEnB70R8owOx3LVpEFvpMK9eeH1o2r0yZhFI9o=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
-github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
-github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
-github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
-github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
-github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
-github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
-github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
-github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
-github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
-github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
-github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
-github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
-github.com/golang/mock v1.5.0/go.mod h1:CWnOUgYIOo4TcNZ0wHX3YZCqsaM1I1Jvs6v3mP3KVu8=
-github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
-github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
-github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
-github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
-github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
-github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=
-github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
-github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
-github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
-github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
-github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
-github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
-github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
-github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
-github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
-github.com/golang/protobuf v1.5.1/go.mod h1:DopwsBzvsk0Fs44TXzsVbJyPhcCPeIwnvohx4u74HPM=
-github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
-github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
-github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
-github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
-github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
-github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
-github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
-github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
-github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
-github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
-github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
-github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
-github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
-github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
-github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
-github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
-github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
-github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
-github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
-github.com/google/pprof v0.0.0-20210122040257-d980be63207e/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
-github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20211214055906-6f57359322fd h1:1FjCyPC+syAzJ5/2S8fqdZK1R22vvA0J7JZKcuOIQ7Y=
github.com/google/pprof v0.0.0-20211214055906-6f57359322fd/go.mod h1:KgnwoLYCZ8IQu3XUZ8Nc/bM9CCZFOyjUNOSygVozoDg=
-github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
-github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
-github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
-github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
-github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
-github.com/gopherjs/gopherjs v0.0.0-20211219123610-ec9572f70e60/go.mod h1:cz9oNYuRUWGdHmLF2IodMLkAhcPtXeULvcBNagUrxTI=
-github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g=
-github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k=
-github.com/goxjs/gl v0.0.0-20210104184919-e3fafc6f8f2a/go.mod h1:dy/f2gjY09hwVfIyATps4G2ai7/hLwLkc5TrPqONuXY=
-github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
-github.com/hashicorp/consul/api v1.1.0/go.mod h1:VmuI/Lkw1nC05EYQWNKwWGbkg+FbDBtguAZLlVdkD9Q=
-github.com/hashicorp/consul/sdk v0.1.1/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8=
-github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
-github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
-github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
-github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM=
-github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk=
-github.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU=
-github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU=
-github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4=
-github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
-github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
-github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90=
-github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
-github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
-github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
-github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64=
-github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ=
-github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I=
-github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc=
-github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
-github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
-github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
-github.com/jeandeaual/go-locale v0.0.0-20240223122105-ce5225dcaa49 h1:Po+wkNdMmN+Zj1tDsJQy7mJlPlwGNQd9JZoPjObagf8=
-github.com/jeandeaual/go-locale v0.0.0-20240223122105-ce5225dcaa49/go.mod h1:YiutDnxPRLk5DLUFj6Rw4pRBBURZY07GFr54NdV9mQg=
-github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
-github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
-github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
+github.com/hack-pad/go-indexeddb v0.3.2 h1:DTqeJJYc1usa45Q5r52t01KhvlSN02+Oq+tQbSBI91A=
+github.com/hack-pad/go-indexeddb v0.3.2/go.mod h1:QvfTevpDVlkfomY498LhstjwbPW6QC4VC/lxYb0Kom0=
+github.com/hack-pad/safejs v0.1.1 h1:d5qPO0iQ7h2oVtpzGnLExE+Wn9AtytxIfltcS2b9KD8=
+github.com/hack-pad/safejs v0.1.1/go.mod h1:HdS+bKF1NrE72VoXZeWzxFOVQVUSqZJAG0xNCnb+Tio=
+github.com/jeandeaual/go-locale v0.0.0-20250421151639-a9d6ed1b3d45 h1:vFdvrlsVU+p/KFBWTq0lTG4fvWvG88sawGlCzM+RUEU=
+github.com/jeandeaual/go-locale v0.0.0-20250421151639-a9d6ed1b3d45/go.mod h1:ZDXo8KHryOWSIqnsb/CiDq7hQUYryCgdVnxbj8tDG7o=
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 h1:YLvr1eE6cdCqjOe972w/cYF+FjW34v27+9Vo5106B4M=
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25/go.mod h1:kLgvv7o6UM+0QSf0QjAse3wReFDsb9qbZJdfexWlrQw=
-github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
-github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
-github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
-github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
-github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
-github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
-github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
-github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
-github.com/magiconair/properties v1.8.5/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60=
-github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
-github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
-github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=
-github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc=
-github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
-github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI=
-github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg=
-github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY=
-github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
-github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
-github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
-github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
-github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
-github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
-github.com/neelance/astrewrite v0.0.0-20160511093645-99348263ae86/go.mod h1:kHJEU3ofeGjhHklVoIGuVj85JJwZ6kWPaJwCIxgnFmo=
-github.com/neelance/sourcemap v0.0.0-20200213170602-2833bce08e4c/go.mod h1:Qr6/a/Q4r9LP1IltGz7tA7iOK1WonHEYhu1HRBA7ZiM=
-github.com/nicksnyder/go-i18n/v2 v2.4.1 h1:zwzjtX4uYyiaU02K5Ia3zSkpJZrByARkRB4V3YPrr0g=
-github.com/nicksnyder/go-i18n/v2 v2.4.1/go.mod h1:++Pl70FR6Cki7hdzZRnEEqdc2dJt+SAGotyFg/SvZMk=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
+github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
+github.com/nicksnyder/go-i18n/v2 v2.6.0 h1:C/m2NNWNiTB6SK4Ao8df5EWm3JETSTIGNXBpMJTxzxQ=
+github.com/nicksnyder/go-i18n/v2 v2.6.0/go.mod h1:88sRqr0C6OPyJn0/KRNaEz1uWorjxIKP7rUUcvycecE=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
-github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
-github.com/pelletier/go-toml v1.9.3/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
-github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/profile v1.7.0 h1:hnbDkaNWPCLMO9wGLdBFTIZvzDrDfBM2072E1S9gJkA=
github.com/pkg/profile v1.7.0/go.mod h1:8Uer0jas47ZQMJ7VD+OHknK4YDY07LPUC6dEvqDjvNo=
-github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
-github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
-github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
-github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
-github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
-github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
-github.com/rymdport/portal v0.2.6 h1:HWmU3gORu7vWcpr7VSwUS2Xx1HtJXVcUuTqEZcMEsIg=
-github.com/rymdport/portal v0.2.6/go.mod h1:kFF4jslnJ8pD5uCi17brj/ODlfIidOxlgUDTO5ncnC4=
-github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
-github.com/shurcooL/go v0.0.0-20200502201357-93f07166e636/go.mod h1:TDJrrUr11Vxrven61rcy3hJMUqaf/CLWYhHNPmT14Lk=
-github.com/shurcooL/httpfs v0.0.0-20190707220628-8d4bc4ba7749/go.mod h1:ZY1cvUeJuFPAdZ/B6v7RHavJWZn2YPVFQ1OSXhCGOkg=
-github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
-github.com/shurcooL/vfsgen v0.0.0-20200824052919-0d455de96546/go.mod h1:TrYk7fJVaAttu97ZZKrO9UbRa8izdowaMIZcxYMbVaw=
-github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
-github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
-github.com/spf13/afero v1.6.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I=
-github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
-github.com/spf13/cobra v1.2.1/go.mod h1:ExllRjgxM/piMAM+3tAZvg8fsklGAf3tPfi+i8t68Nk=
-github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo=
-github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
-github.com/spf13/viper v1.8.1/go.mod h1:o0Pch8wJ9BVSWGQMbra6iw0oQ5oktSIBaujf1rJH9Ns=
+github.com/rymdport/portal v0.4.1 h1:2dnZhjf5uEaeDjeF/yBIeeRo6pNI2QAKm7kq1w/kbnA=
+github.com/rymdport/portal v0.4.1/go.mod h1:kFF4jslnJ8pD5uCi17brj/ODlfIidOxlgUDTO5ncnC4=
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE=
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q=
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ=
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE=
-github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
-github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
-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/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
-github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
-github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
-github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
-github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
-github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
-github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
-github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
-github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
-github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
-github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
-github.com/yuin/goldmark v1.4.0/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
-github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
-github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
-go.etcd.io/etcd/api/v3 v3.5.0/go.mod h1:cbVKeC6lCfl7j/8jBhAK6aIYO9XOjdptoxU/nLQcPvs=
-go.etcd.io/etcd/client/pkg/v3 v3.5.0/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g=
-go.etcd.io/etcd/client/v2 v2.305.0/go.mod h1:h9puh54ZTgAKtEbut2oe9P4L/oqKCVB6xsXlzd7alYQ=
-go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
-go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
-go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
-go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
-go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
-go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
-go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E=
-go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
-go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
-go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo=
-golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
-golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
-golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
-golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
-golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
-golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
-golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
-golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
-golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
-golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
-golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
-golang.org/x/exp v0.0.0-20190731235908-ec7cb31e5a56/go.mod h1:JhuoJpWY28nO4Vef9tZUw9qufEGTyX1+7lmHxV5q5G4=
-golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek=
-golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
-golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
-golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
-golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
-golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
-golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
-golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
-golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
-golang.org/x/image v0.22.0 h1:UtK5yLUzilVrkjMAZAZ34DXGpASN8i8pj8g+O+yd10g=
-golang.org/x/image v0.22.0/go.mod h1:9hPFhljd4zZ1GNSIZJ49sqbp45GKK9t6w+iXvGqZUz4=
-golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
-golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
-golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
-golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
-golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
-golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
-golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
-golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs=
-golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
-golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
-golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
-golang.org/x/lint v0.0.0-20210508222113-6edffad5e616/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
-golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
-golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
-golang.org/x/mobile v0.0.0-20211207041440-4e6c2922fdee/go.mod h1:pe2sM7Uk+2Su1y7u/6Z8KJ24D7lepUjFZbhFOrmDfuQ=
-golang.org/x/mobile v0.0.0-20241108191957-fa514ef75a0f h1:23H/YlmTHfmmvpZ+ajKZL0qLz0+IwFOIqQA0mQbmLeM=
-golang.org/x/mobile v0.0.0-20241108191957-fa514ef75a0f/go.mod h1:UbSUP4uu/C9hw9R2CkojhXlAxvayHjBdU9aRvE+c1To=
-golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
-golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
-golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
-golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
-golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
-golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
-golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
-golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
-golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
-golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
-golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
-golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
-golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
-golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
-golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
-golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
-golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
-golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
-golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
-golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
-golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
-golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
-golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
-golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
-golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
-golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
-golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
-golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
-golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc=
-golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
-golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
-golang.org/x/net v0.31.0 h1:68CPQngjLL0r2AlUKiSxtQFKvzRVbnzLwMUn5SzcLHo=
-golang.org/x/net v0.31.0/go.mod h1:P4fl1q7dY2hnZFxEk4pPSkDHF+QqjitcnDjUQyMM+pM=
-golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
-golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
-golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
-golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
-golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
-golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
-golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
-golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
-golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
-golang.org/x/oauth2 v0.0.0-20210220000619-9bb904979d93/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
-golang.org/x/oauth2 v0.0.0-20210313182246-cd4f82c27b84/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
-golang.org/x/oauth2 v0.0.0-20210402161424-2e8d93401602/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
-golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210220050731-9a76102bfb43/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210305230114-8fe3ee5dd75b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210315160823-c6e025ad8005/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210403161142-5e06dd20ab57/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s=
-golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
-golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
-golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
-golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
-golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
-golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
-golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
-golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
-golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
-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/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
-golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
-golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
-golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
-golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
-golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
-golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
-golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
-golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
-golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
-golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
-golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
-golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.0.0-20191112195655-aa38f8e97acc/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
-golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
-golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
-golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
-golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
-golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
-golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
-golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
-golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
-golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
-golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
-golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
-golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8=
-golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
-golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
-golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
-golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
-golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
-golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
-golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
-golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
-golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE=
-golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
-golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
-golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
-golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
-golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
-golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
-golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
-golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
-golang.org/x/tools v0.1.8-0.20211022200916-316ba0b74098/go.mod h1:LGqMHiF4EqQNHR1JncWGqT5BVaXmza+X+BDGol+dOxo=
-golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
-google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
-google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
-google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
-google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
-google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
-google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
-google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
-google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
-google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
-google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
-google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
-google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
-google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
-google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM=
-google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc=
-google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg=
-google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE=
-google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8=
-google.golang.org/api v0.41.0/go.mod h1:RkxM5lITDfTzmyKFPt+wGrCJbVfniCr2ool8kTBzRTU=
-google.golang.org/api v0.43.0/go.mod h1:nQsDGjRXMo4lvh5hP0TKqF244gqhGcr/YSIykhUk/94=
-google.golang.org/api v0.44.0/go.mod h1:EBOGZqzyhtvMDoxwS97ctnh0zUmYY6CxqXsc1AvkYD8=
-google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
-google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
-google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
-google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
-google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
-google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
-google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
-google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
-google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
-google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
-google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
-google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
-google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
-google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
-google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8=
-google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
-google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
-google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
-google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
-google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
-google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
-google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA=
-google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
-google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
-google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
-google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
-google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
-google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
-google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
-google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
-google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
-google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U=
-google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
-google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA=
-google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
-google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
-google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
-google.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
-google.golang.org/genproto v0.0.0-20201109203340-2640f1f9cdfb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
-google.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
-google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
-google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
-google.golang.org/genproto v0.0.0-20210222152913-aa3ee6e6a81c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
-google.golang.org/genproto v0.0.0-20210303154014-9728d6b83eeb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
-google.golang.org/genproto v0.0.0-20210310155132-4ce2db91004e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
-google.golang.org/genproto v0.0.0-20210319143718-93e7006c17a6/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
-google.golang.org/genproto v0.0.0-20210402141018-6c239bbf2bb1/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A=
-google.golang.org/genproto v0.0.0-20210602131652-f16073e35f0c/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0=
-google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
-google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
-google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
-google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
-google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
-google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
-google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
-google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
-google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60=
-google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk=
-google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
-google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
-google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
-google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0=
-google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
-google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8=
-google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
-google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
-google.golang.org/grpc v1.36.1/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
-google.golang.org/grpc v1.38.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM=
-google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
-google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
-google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
-google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
-google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
-google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
-google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
-google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
-google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4=
-google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
-google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
-google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
+github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
+github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/yuin/goldmark v1.7.10 h1:S+LrtBjRmqMac2UdtB6yyCEJm+UILZ2fefI4p7o0QpI=
+github.com/yuin/goldmark v1.7.10/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
+golang.org/x/image v0.26.0 h1:4XjIFEZWQmCZi6Wv8BoxsDhRU3RVnLX04dToTDAEPlY=
+golang.org/x/image v0.26.0/go.mod h1:lcxbMFAovzpnJxzXS3nyL83K27tmqtKzIJpctK8YO5c=
+golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
+golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
+golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
+golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
+golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
+golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
-gopkg.in/ini.v1 v1.62.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
-gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
-gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
-honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
-honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
-honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
-honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
-honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
-honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
-rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
-rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
-rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
diff --git a/xA/xA.go b/xA/xA.go
index f61102e..b4f796c 100644
--- a/xA/xA.go
+++ b/xA/xA.go
@@ -1,30 +1,37 @@
-// Copyright (c) 2024, Přemysl Eric Janouch <p@janouch.name>
+// Copyright (c) 2024 - 2025, Přemysl Eric Janouch <p@janouch.name>
// SPDX-License-Identifier: 0BSD
package main
import (
"bufio"
+ "bytes"
"context"
_ "embed"
"encoding/binary"
+ "errors"
"flag"
"fmt"
"image/color"
"io"
"log"
"net"
+ "net/url"
"os"
+ "regexp"
"slices"
"strings"
"sync"
"time"
+ "github.com/ebitengine/oto/v3"
+
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/app"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/dialog"
"fyne.io/fyne/v2/driver/desktop"
+ "fyne.io/fyne/v2/driver/mobile"
"fyne.io/fyne/v2/theme"
"fyne.io/fyne/v2/widget"
)
@@ -38,6 +45,8 @@ var (
iconNormal []byte
//go:embed xA-highlighted.png
iconHighlighted []byte
+ //go:embed beep.raw
+ beepSample []byte
resourceIconNormal = fyne.NewStaticResource(
"xA.png", iconNormal)
@@ -49,6 +58,16 @@ var (
type customTheme struct{}
+const (
+ colorNameRenditionError fyne.ThemeColorName = "renditionError"
+ colorNameRenditionJoin fyne.ThemeColorName = "renditionJoin"
+ colorNameRenditionPart fyne.ThemeColorName = "renditionPart"
+ colorNameRenditionAction fyne.ThemeColorName = "renditionAction"
+
+ colorNameBufferTimestamp fyne.ThemeColorName = "bufferTimestamp"
+ colorNameBufferLeaked fyne.ThemeColorName = "bufferLeaked"
+)
+
func convertColor(c int) color.Color {
base16 := []uint16{
0x000, 0x800, 0x080, 0x880, 0x008, 0x808, 0x088, 0xccc,
@@ -103,6 +122,10 @@ func (t *customTheme) Color(
variant = theme.VariantDark
}
*/
+ /*
+ // Fyne 2.6.0 has a different bug, the Light variant is not applied:
+ variant = theme.VariantLight
+ */
// Fuck this low contrast shit, text must be black.
if name == theme.ColorNameForeground &&
@@ -110,7 +133,20 @@ func (t *customTheme) Color(
return color.Black
}
- // TODO(p): Consider constants for stuff like timestamps.
+ switch name {
+ case colorNameRenditionError:
+ return color.RGBA{0xff, 0x00, 0x00, 0xff}
+ case colorNameRenditionJoin:
+ return color.RGBA{0x00, 0x88, 0x00, 0xff}
+ case colorNameRenditionPart:
+ return color.RGBA{0x88, 0x00, 0x00, 0xff}
+ case colorNameRenditionAction:
+ return color.RGBA{0x88, 0x00, 0x00, 0xff}
+
+ case colorNameBufferTimestamp, colorNameBufferLeaked:
+ return color.RGBA{0x88, 0x88, 0x88, 0xff}
+ }
+
if c, ok := ircColors[name]; ok {
return c
}
@@ -149,6 +185,7 @@ type bufferLineItem struct {
// XXX: Fyne's RichText doesn't support background colours.
background fyne.ThemeColorName
text string
+ link *url.URL
}
type bufferLine struct {
@@ -182,14 +219,18 @@ type buffer struct {
// Input:
- input string
- inputStart, inputEnd int
- history []string
- historyAt int
+ input string
+ inputRow, inputColumn int
+ history []string
+ historyAt int
}
type callback func(err string, response *RelayResponseData)
+const (
+ preferenceAddress = "address"
+)
+
var (
backendAddress string
backendContext context.Context
@@ -209,25 +250,47 @@ var (
servers = make(map[string]*server)
+ // Sound:
+
+ otoContext *oto.Context
+ otoReady chan struct{}
+
// Widgets:
inForeground = true
+ wConnect *dialog.FormDialog
+
wWindow fyne.Window
wTopic *widget.RichText
wBufferList *widget.List
wRichText *widget.RichText
wRichScroll *container.Scroll
+ wLog *logEntry
wPrompt *widget.Label
+ wDown *widget.Icon
wStatus *widget.Label
- wEntry *customEntry
+ wEntry *inputEntry
)
// -----------------------------------------------------------------------------
+func showErrorMessage(text string) {
+ dialog.ShowError(errors.New(text), wWindow)
+}
+
func beep() {
- // TODO(p): Probably implement using https://github.com/ebitengine/oto
- // and a sample generated from the Makefile like with xW.
+ if otoContext == nil {
+ return
+ }
+ go func() {
+ <-otoReady
+ p := otoContext.NewPlayer(bytes.NewReader(beepSample))
+ p.Play()
+ for p.IsPlaying() {
+ time.Sleep(time.Second)
+ }
+ }()
}
// --- Networking --------------------------------------------------------------
@@ -274,9 +337,14 @@ func relaySend(data RelayCommandData, callback callback) bool {
CommandSeq: commandSeq,
Data: data,
}
- if callback != nil {
- commandCallbacks[m.CommandSeq] = callback
+ if callback == nil {
+ callback = func(err string, response *RelayResponseData) {
+ if response == nil {
+ showErrorMessage(err)
+ }
+ }
}
+ commandCallbacks[m.CommandSeq] = callback
commandSeq++
// TODO(p): Handle errors better.
@@ -308,23 +376,9 @@ func bufferByName(name string) *buffer {
return nil
}
-func bufferActivate(name string) {
- relaySend(RelayCommandData{
- Variant: &RelayCommandDataBufferActivate{BufferName: name},
- }, nil)
-}
-
-func bufferToggleUnimportant(name string) {
- relaySend(RelayCommandData{
- Variant: &RelayCommandDataBufferToggleUnimportant{BufferName: name},
- }, nil)
-}
-
-// --- Current buffer ----------------------------------------------------------
-
func bufferAtBottom() bool {
- // TODO(p): Figure out how to implement this.
- return false
+ return wRichScroll.Offset.Y >=
+ wRichScroll.Content.Size().Height-wRichScroll.Size().Height
}
func bufferScrollToBottom() {
@@ -333,29 +387,41 @@ func bufferScrollToBottom() {
// to a buffer than needs scrolling.)
wRichScroll.ScrollToBottom()
wRichScroll.ScrollToBottom()
+ refreshStatus()
+}
+
+func bufferPushLine(b *buffer, line bufferLine) {
+ b.lines = append(b.lines, line)
+
+ // Fyne's text layouting is extremely slow.
+ // The limit could be made configurable,
+ // and we could use a ring buffer approach to storing the lines.
+ if len(b.lines) > 100 {
+ b.lines = slices.Delete(b.lines, 0, 1)
+ }
}
// --- UI state refresh --------------------------------------------------------
func refreshIcon() {
- highlighted := false
+ resource := resourceIconNormal
for _, b := range buffers {
if b.highlighted {
- highlighted = true
+ resource = resourceIconHighlighted
break
}
}
-
- if highlighted {
- wWindow.SetIcon(resourceIconHighlighted)
- } else {
- wWindow.SetIcon(resourceIconNormal)
- }
+ wWindow.SetIcon(resource)
}
func refreshTopic(topic []bufferLineItem) {
wTopic.Segments = nil
for _, item := range topic {
+ if item.link != nil {
+ wTopic.Segments = append(wTopic.Segments,
+ &widget.HyperlinkSegment{Text: item.text, URL: item.link})
+ continue
+ }
wTopic.Segments = append(wTopic.Segments, &widget.TextSegment{
Text: item.text,
Style: widget.RichTextStyle{
@@ -371,9 +437,7 @@ func refreshTopic(topic []bufferLineItem) {
}
func refreshBufferList() {
- // TODO(p): See if this is enough, or even doing anything.
- // - In particular, within RelayEventDataBufferRemove handling.
- wBufferList.Refresh()
+ // This seems to be enough, even for removals.
for i := range buffers {
wBufferList.RefreshItem(widget.ListItemID(i))
}
@@ -396,12 +460,13 @@ func refreshPrompt() {
}
func refreshStatus() {
- var status string
- if !bufferAtBottom() {
- status += "🡇 "
+ if bufferAtBottom() {
+ wDown.Hide()
+ } else {
+ wDown.Show()
}
- status += bufferCurrent
+ status := bufferCurrent
if b := bufferByName(bufferCurrent); b != nil {
if b.modes != "" {
status += "(+" + b.modes + ")"
@@ -414,6 +479,63 @@ func refreshStatus() {
wStatus.SetText(status)
}
+func recheckHighlighted() {
+ // Corresponds to the logic toggling the bool on.
+ if b := bufferByName(bufferCurrent); b != nil &&
+ b.highlighted && bufferAtBottom() &&
+ inForeground && !wLog.Visible() {
+ b.highlighted = false
+ refreshIcon()
+ refreshBufferList()
+ }
+}
+
+// --- Buffer actions ----------------------------------------------------------
+
+func bufferActivate(name string) {
+ relaySend(RelayCommandData{
+ Variant: &RelayCommandDataBufferActivate{BufferName: name},
+ }, nil)
+}
+
+func bufferToggleUnimportant(name string) {
+ relaySend(RelayCommandData{
+ Variant: &RelayCommandDataBufferToggleUnimportant{BufferName: name},
+ }, nil)
+}
+
+func bufferToggleLogFinish(err string, response *RelayResponseDataBufferLog) {
+ if response == nil {
+ showErrorMessage(err)
+ return
+ }
+
+ wLog.SetText(string(response.Log))
+ wLog.Show()
+ wRichScroll.Hide()
+}
+
+func bufferToggleLog() {
+ if wLog.Visible() {
+ wRichScroll.Show()
+ wLog.Hide()
+ wLog.SetText("")
+
+ recheckHighlighted()
+ return
+ }
+
+ name := bufferCurrent
+ relaySend(RelayCommandData{Variant: &RelayCommandDataBufferLog{
+ BufferName: name,
+ }}, func(err string, response *RelayResponseData) {
+ if bufferCurrent == name {
+ bufferToggleLogFinish(
+ err, response.Variant.(*RelayResponseDataBufferLog))
+ }
+ })
+}
+
// --- RichText formatting -----------------------------------------------------
func defaultBufferLineItem() bufferLineItem { return bufferLineItem{} }
@@ -450,6 +572,39 @@ func convertItemFormatting(
}
}
+var linkRE = regexp.MustCompile(`https?://` +
+ `(?:[^\[\](){}<>"'\s]|\([^\[\](){}<>"'\s]*\))+` +
+ `(?:[^\[\](){}<>"'\s,.:]|\([^\[\](){}<>"'\s]*\))`)
+
+func convertLinks(
+ item bufferLineItem, items []bufferLineItem) []bufferLineItem {
+ end, matches := 0, linkRE.FindAllStringIndex(item.text, -1)
+ for _, m := range matches {
+ url, _ := url.Parse(item.text[m[0]:m[1]])
+ if url == nil {
+ continue
+ }
+ if end < m[0] {
+ subitem := item
+ subitem.text = item.text[end:m[0]]
+ items = append(items, subitem)
+ }
+
+ subitem := item
+ subitem.text = item.text[m[0]:m[1]]
+ subitem.link = url
+ items = append(items, subitem)
+
+ end = m[1]
+ }
+ if end < len(item.text) {
+ subitem := item
+ subitem.text = item.text[end:]
+ items = append(items, subitem)
+ }
+ return items
+}
+
func convertItems(items []RelayItemData) []bufferLineItem {
result := []bufferLineItem{}
cf, inverse := defaultBufferLineItem(), false
@@ -465,7 +620,7 @@ func convertItems(items []RelayItemData) []bufferLineItem {
if inverse {
item.color, item.background = item.background, item.color
}
- result = append(result, item)
+ result = convertLinks(item, result)
}
return result
}
@@ -525,19 +680,17 @@ func bufferPrintLine(lines []bufferLine, index int) {
bufferPrintDateChange(last, current)
- // TODO(p): Why don't the colour names work?
texts := []widget.RichTextSegment{&widget.TextSegment{
Text: line.when.Format("15:04:05 "),
Style: widget.RichTextStyle{
Alignment: fyne.TextAlignLeading,
- ColorName: "",
+ ColorName: colorNameBufferTimestamp,
Inline: true,
SizeName: theme.SizeNameText,
TextStyle: fyne.TextStyle{},
}}}
// Tabstops won't quite help us here, since we need it centred.
- // TODO(p): Why don't the colour names work?
prefix := ""
pcf := widget.RichTextStyle{
Alignment: fyne.TextAlignLeading,
@@ -553,48 +706,48 @@ func bufferPrintLine(lines []bufferLine, index int) {
prefix = " - "
case RelayRenditionError:
prefix = "=!= "
- //pcf.ColorName = theme.ColorRed
+ pcf.ColorName = colorNameRenditionError
case RelayRenditionJoin:
prefix = "--> "
- //pcf.ColorName = theme.ColorGreen
+ pcf.ColorName = colorNameRenditionJoin
case RelayRenditionPart:
prefix = "<-- "
- //pcf.ColorName = theme.ColorRed
+ pcf.ColorName = colorNameRenditionPart
case RelayRenditionAction:
prefix = " * "
- //pcf.ColorName = theme.ColorRed
+ pcf.ColorName = colorNameRenditionAction
}
- // TODO(p): Also scan for URLs, and transform line.items before use.
- // &widget.HyperlinkSegment{Text: "X", URL: testURL},
-
- // TODO(p): Render timestamps as well.
- if line.leaked {
- // TODO(p): Similar as below, but everything greyed out.
- if prefix != "" {
+ if prefix != "" {
+ style := pcf
+ if line.leaked {
+ style.ColorName = colorNameBufferLeaked
}
- for _, item := range line.items {
- _ = item
+ texts = append(texts, &widget.TextSegment{
+ Text: prefix,
+ Style: style,
+ })
+ }
+ for _, item := range line.items {
+ if item.link != nil {
+ texts = append(texts,
+ &widget.HyperlinkSegment{Text: item.text, URL: item.link})
+ continue
}
- } else {
- if prefix != "" {
- texts = append(texts, &widget.TextSegment{
- Text: prefix,
- Style: pcf,
- })
+ style := widget.RichTextStyle{
+ Alignment: fyne.TextAlignLeading,
+ ColorName: item.color,
+ Inline: true,
+ SizeName: theme.SizeNameText,
+ TextStyle: item.format,
}
- for _, item := range line.items {
- texts = append(texts, &widget.TextSegment{
- Text: item.text,
- Style: widget.RichTextStyle{
- Alignment: fyne.TextAlignLeading,
- ColorName: item.color,
- Inline: true,
- SizeName: theme.SizeNameText,
- TextStyle: item.format,
- },
- })
+ if line.leaked {
+ style.ColorName = colorNameBufferLeaked
}
+ texts = append(texts, &widget.TextSegment{
+ Text: item.text,
+ Style: style,
+ })
}
wRichText.Segments = append(wRichText.Segments,
@@ -624,6 +777,7 @@ func refreshBuffer(b *buffer) {
bufferPrintAndWatchTrailingDateChanges()
wRichText.Refresh()
bufferScrollToBottom()
+ recheckHighlighted()
}
// --- Event processing --------------------------------------------------------
@@ -634,7 +788,7 @@ func relayProcessBufferLine(b *buffer, m *RelayEventDataBufferLine) {
// Initial sync: skip all other processing, let highlights be.
bc := bufferByName(bufferCurrent)
if bc == nil {
- b.lines = append(b.lines, line)
+ bufferPushLine(b, line)
return
}
@@ -642,11 +796,11 @@ func relayProcessBufferLine(b *buffer, m *RelayEventDataBufferLine) {
display := (!m.IsUnimportant || !bc.hideUnimportant) &&
(b.bufferName == bufferCurrent || m.LeakToActive)
toBottom := display && bufferAtBottom()
- visible := display && toBottom && inForeground // && log not visible
+ visible := display && toBottom && inForeground && !wLog.Visible()
separate := display &&
!visible && bc.newMessages == 0 && bc.newUnimportantMessages == 0
- b.lines = append(b.lines, line)
+ bufferPushLine(b, line)
if !(visible || m.LeakToActive) ||
b.newMessages != 0 || b.newUnimportantMessages != 0 {
if line.isUnimportant || m.LeakToActive {
@@ -659,7 +813,7 @@ func relayProcessBufferLine(b *buffer, m *RelayEventDataBufferLine) {
if m.LeakToActive {
leakedLine := line
leakedLine.leaked = true
- bc.lines = append(bc.lines, leakedLine)
+ bufferPushLine(bc, leakedLine)
if !visible || bc.newMessages != 0 || bc.newUnimportantMessages != 0 {
if line.isUnimportant {
@@ -682,6 +836,7 @@ func relayProcessBufferLine(b *buffer, m *RelayEventDataBufferLine) {
}
// TODO(p): On mobile, we should probably send notifications.
+ // Though we probably can't run in the background.
if line.isHighlight || (!visible && !line.isUnimportant &&
b.kind == RelayBufferKindPrivateMessage) {
beep()
@@ -699,7 +854,7 @@ func relayProcessCallbacks(
commandSeq uint32, err string, response *RelayResponseData) {
if handler, ok := commandCallbacks[commandSeq]; !ok {
if *debug {
- log.Printf("unawaited response: %+v\n", *response)
+ log.Printf("Unawaited response: %+v\n", *response)
}
} else {
delete(commandCallbacks, commandSeq)
@@ -788,11 +943,11 @@ func relayProcessMessage(m *RelayEventMessage) {
b.bufferName = data.New
- refreshBufferList()
if data.BufferName == bufferCurrent {
bufferCurrent = data.New
refreshStatus()
}
+ refreshBufferList()
if data.BufferName == bufferLast {
bufferLast = data.New
}
@@ -818,15 +973,21 @@ func relayProcessMessage(m *RelayEventMessage) {
old.highlighted = false
old.input = wEntry.Text
- // TODO(p): At least store Cursor{Row,Column}.
+ old.inputRow = wEntry.CursorRow
+ old.inputColumn = wEntry.CursorColumn
// Note that we effectively overwrite the newest line
// with the current textarea contents, and jump there.
old.historyAt = len(old.history)
}
- // TODO(p): Hide the log if visible.
- b.highlighted = false
+ if wLog.Visible() {
+ bufferToggleLog()
+ }
+ if inForeground {
+ b.highlighted = false
+ }
+
for i := range buffers {
if buffers[i].bufferName == bufferCurrent {
wBufferList.Select(widget.ListItemID(i))
@@ -841,8 +1002,10 @@ func relayProcessMessage(m *RelayEventMessage) {
refreshPrompt()
refreshStatus()
- // TODO(p): At least set Cursor{Row,Column}, and apply it.
wEntry.SetText(b.input)
+ wEntry.CursorRow = b.inputRow
+ wEntry.CursorColumn = b.inputColumn
+ wEntry.Refresh()
wWindow.Canvas().Focus(wEntry)
case *RelayEventDataBufferInput:
b := bufferByName(data.BufferName)
@@ -916,19 +1079,42 @@ func relayMakeReceiver(
return p
}
+func relayResetState() {
+ commandSeq = 0
+ commandCallbacks = make(map[uint32]callback)
+
+ buffers = nil
+ bufferCurrent = ""
+ bufferLast = ""
+ servers = make(map[string]*server)
+
+ refreshIcon()
+ refreshTopic(nil)
+ refreshBufferList()
+ wRichText.ParseMarkdown("")
+ refreshPrompt()
+ refreshStatus()
+}
+
func relayRun() {
- // TODO(p): Maybe reset state, and indicate in the UI that we're connecting.
+ fyne.CurrentApp().Preferences().SetString(preferenceAddress, backendAddress)
+ backendLock.Lock()
+
+ fyne.DoAndWait(func() {
+ relayResetState()
+ })
backendContext, backendCancel = context.WithCancel(context.Background())
defer backendCancel()
-
var err error
- backendLock.Lock()
backendConn, err = net.Dial("tcp", backendAddress)
+
backendLock.Unlock()
if err != nil {
- log.Println("Connection failed: " + err.Error())
- // TODO(p): Display errors to the user.
+ fyne.DoAndWait(func() {
+ wConnect.Show()
+ showErrorMessage("Connection failed: " + err.Error())
+ })
return
}
defer backendConn.Close()
@@ -941,23 +1127,30 @@ func relayRun() {
}, nil)
relayMessages := relayMakeReceiver(backendContext, backendConn)
+Loop:
for {
select {
case m, ok := <-relayMessages:
if !ok {
- break
+ break Loop
}
- relayProcessMessage(&m)
- default:
- break
+ fyne.DoAndWait(func() {
+ relayProcessMessage(&m)
+ })
}
}
-
- // TODO(p): Indicate in the UI that we're no longer connected.
+ fyne.DoAndWait(func() {
+ wConnect.Show()
+ showErrorMessage("Disconnected")
+ })
}
// --- Input line --------------------------------------------------------------
+func inputSetContents(input string) {
+ wEntry.SetText(input)
+}
+
func inputSubmit(text string) bool {
b := bufferByName(bufferCurrent)
if b == nil {
@@ -966,7 +1159,7 @@ func inputSubmit(text string) bool {
b.history = append(b.history, text)
b.historyAt = len(b.history)
- wEntry.SetText("")
+ inputSetContents("")
relaySend(RelayCommandData{Variant: &RelayCommandDataBufferInput{
BufferName: b.bufferName,
@@ -975,67 +1168,156 @@ func inputSubmit(text string) bool {
return true
}
+type inputStamp struct {
+ cursorRow, cursorColumn int
+ input string
+}
+
+func inputGetStamp() inputStamp {
+ return inputStamp{
+ cursorRow: wEntry.CursorRow,
+ cursorColumn: wEntry.CursorColumn,
+ input: wEntry.Text,
+ }
+}
+
+func inputCompleteFinish(state inputStamp,
+ err string, response *RelayResponseDataBufferComplete) {
+ if response == nil {
+ showErrorMessage(err)
+ return
+ }
+
+ if len(response.Completions) > 0 {
+ insert := response.Completions[0]
+ if len(response.Completions) == 1 {
+ insert += " "
+ }
+ inputSetContents(state.input[:response.Start] + insert)
+
+ }
+ if len(response.Completions) != 1 {
+ beep()
+ }
+
+ // TODO(p): Show all completion options.
+}
+
+func inputComplete() bool {
+ if wEntry.SelectedText() != "" {
+ return false
+ }
+
+ // XXX: Fyne's Entry widget makes it impossible to handle this properly.
+ state := inputGetStamp()
+ relaySend(RelayCommandData{Variant: &RelayCommandDataBufferComplete{
+ BufferName: bufferCurrent,
+ Text: state.input,
+ Position: uint32(len(state.input)),
+ }}, func(err string, response *RelayResponseData) {
+ if stamp := inputGetStamp(); state == stamp {
+ inputCompleteFinish(state,
+ err, response.Variant.(*RelayResponseDataBufferComplete))
+ }
+ })
+ return true
+}
+
+func inputUp() bool {
+ b := bufferByName(bufferCurrent)
+ if b == nil || b.historyAt < 1 {
+ return false
+ }
+
+ if b.historyAt == len(b.history) {
+ b.input = wEntry.Text
+ }
+ b.historyAt--
+ inputSetContents(b.history[b.historyAt])
+ return true
+}
+
+func inputDown() bool {
+ b := bufferByName(bufferCurrent)
+ if b == nil || b.historyAt >= len(b.history) {
+ return false
+ }
+
+ b.historyAt++
+ if b.historyAt == len(b.history) {
+ inputSetContents(b.input)
+ } else {
+ inputSetContents(b.history[b.historyAt])
+ }
+ return true
+}
+
// --- General UI --------------------------------------------------------------
-type customEntry struct {
+type inputEntry struct {
widget.Entry
// selectKeyDown is a hack to exactly invert widget.Entry's behaviour,
// which groups both Shift keys together.
selectKeyDown bool
-
- down map[fyne.KeyName]bool
}
-func newCustomEntry() *customEntry {
- e := &customEntry{}
+func newInputEntry() *inputEntry {
+ e := &inputEntry{}
e.MultiLine = true
e.Wrapping = fyne.TextWrap(fyne.TextTruncateClip)
- e.down = make(map[fyne.KeyName]bool)
e.ExtendBaseWidget(e)
return e
}
-func (e *customEntry) FocusLost() {
- e.down = make(map[fyne.KeyName]bool)
+func (e *inputEntry) FocusLost() {
e.selectKeyDown = false
e.Entry.FocusLost()
}
-func (e *customEntry) KeyDown(key *fyne.KeyEvent) {
- e.down[key.Name] = true
+func (e *inputEntry) KeyDown(key *fyne.KeyEvent) {
+ // TODO(p): And perhaps on other actions, too.
+ relaySend(RelayCommandData{Variant: &RelayCommandDataActive{}}, nil)
+
+ // Modified events are eaten somewhere, not reaching TypedKey or Shortcuts.
+ if dd, ok := fyne.CurrentApp().Driver().(desktop.Driver); ok {
+ modifiedKey := desktop.CustomShortcut{
+ KeyName: key.Name, Modifier: dd.CurrentKeyModifiers()}
+ if handler := shortcuts[modifiedKey]; handler != nil {
+ handler()
+ return
+ }
+
+ switch {
+ case modifiedKey.Modifier == fyne.KeyModifierControl &&
+ modifiedKey.KeyName == fyne.KeyP:
+ inputUp()
+ return
+ case modifiedKey.Modifier == fyne.KeyModifierControl &&
+ modifiedKey.KeyName == fyne.KeyN:
+ inputDown()
+ return
+ }
+ }
+
if key.Name == desktop.KeyShiftLeft || key.Name == desktop.KeyShiftRight {
e.selectKeyDown = true
}
e.Entry.KeyDown(key)
}
-func (e *customEntry) KeyUp(key *fyne.KeyEvent) {
- delete(e.down, key.Name)
+func (e *inputEntry) KeyUp(key *fyne.KeyEvent) {
if key.Name == desktop.KeyShiftLeft || key.Name == desktop.KeyShiftRight {
e.selectKeyDown = false
}
e.Entry.KeyUp(key)
}
-func (e *customEntry) TypedKey(key *fyne.KeyEvent) {
+func (e *inputEntry) TypedKey(key *fyne.KeyEvent) {
if e.Disabled() {
return
}
- /*
- modified := false
- for _, name := range []fyne.KeyName{
- desktop.KeyAltLeft, desktop.KeyAltRight,
- desktop.KeyControlLeft, desktop.KeyControlRight,
- desktop.KeySuperLeft, desktop.KeySuperRight,
- } {
- if e.down[name] {
- modified = true
- }
- }
- */
-
// Invert the Shift key behaviour here.
// Notice that this will never work on mobile.
shift := &fyne.KeyEvent{Name: desktop.KeyShiftLeft}
@@ -1049,15 +1331,173 @@ func (e *customEntry) TypedKey(key *fyne.KeyEvent) {
e.OnSubmitted(e.Text)
}
case fyne.KeyTab:
- // TODO(p): Just do e.Append() if state matches.
- log.Println("completion")
+ if e.selectKeyDown {
+ // This could also go through completion lists.
+ wWindow.Canvas().FocusPrevious()
+ } else {
+ inputComplete()
+ }
default:
e.Entry.TypedKey(key)
}
}
+func (e *inputEntry) SetText(text string) {
+ e.Entry.SetText(text)
+ if text != "" {
+ e.Entry.TypedKey(&fyne.KeyEvent{Name: fyne.KeyPageDown})
+ }
+}
+
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+type logEntry struct {
+ // XXX: Sadly, we can't seem to make it actually read-only.
+ // https://github.com/fyne-io/fyne/issues/5263
+ widget.Entry
+}
+
+func newLogEntry() *logEntry {
+ e := &logEntry{}
+ e.MultiLine = true
+ e.Wrapping = fyne.TextWrapWord
+ e.ExtendBaseWidget(e)
+ return e
+}
+
+func (e *logEntry) SetText(text string) {
+ e.OnChanged = nil
+ e.Entry.SetText(text)
+ e.OnChanged = func(string) { e.Entry.SetText(text) }
+}
+
+func (e *logEntry) AcceptsTab() bool {
+ return false
+}
+
+// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+
+type customLayout struct{}
+
+func (l *customLayout) MinSize(objects []fyne.CanvasObject) fyne.Size {
+ var w, h float32 = 0, 0
+ for _, o := range objects {
+ size := o.MinSize()
+ if w < size.Width {
+ w = size.Width
+ }
+ if h < size.Height {
+ h = size.Height
+ }
+ }
+ return fyne.NewSize(w, h)
+}
+
+func (l *customLayout) Layout(objects []fyne.CanvasObject, size fyne.Size) {
+ // It is not otherwise possible to be notified of resizes.
+ // Embedding container.Scroll either directly or as a pointer
+ // to override its Resize method results in brokenness.
+ toBottom := bufferAtBottom()
+ for _, o := range objects {
+ o.Move(fyne.NewPos(0, 0))
+ o.Resize(size)
+ }
+ if toBottom {
+ bufferScrollToBottom()
+ } else {
+ recheckHighlighted()
+ refreshStatus()
+ }
+}
+
+// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+
+// rotatedBuffers returns buffer indexes starting with the current buffer.
+func rotatedBuffers() []int {
+ r, start := make([]int, len(buffers)), 0
+ for i := range buffers {
+ if buffers[i].bufferName == bufferCurrent {
+ start = i
+ break
+ }
+ }
+ for i := range r {
+ start++
+ r[i] = start % len(r)
+ }
+ return r
+}
+
+var shortcuts = map[desktop.CustomShortcut]func(){
+ {
+ KeyName: fyne.KeyPageUp,
+ Modifier: fyne.KeyModifierControl,
+ }: func() {
+ if r := rotatedBuffers(); len(r) <= 0 {
+ } else if i := r[len(r)-1]; i == 0 {
+ bufferActivate(buffers[len(buffers)-1].bufferName)
+ } else {
+ bufferActivate(buffers[i-1].bufferName)
+ }
+ },
+ {
+ KeyName: fyne.KeyPageDown,
+ Modifier: fyne.KeyModifierControl,
+ }: func() {
+ if r := rotatedBuffers(); len(r) <= 0 {
+ } else {
+ bufferActivate(buffers[r[0]].bufferName)
+ }
+ },
+ {
+ KeyName: fyne.KeyTab,
+ Modifier: fyne.KeyModifierAlt,
+ }: func() {
+ if bufferLast != "" {
+ bufferActivate(bufferLast)
+ }
+ },
+ {
+ // XXX: This makes an assumption on the keyboard layout (we want '!').
+ KeyName: fyne.Key1,
+ Modifier: fyne.KeyModifierAlt | fyne.KeyModifierShift,
+ }: func() {
+ for _, i := range rotatedBuffers() {
+ if buffers[i].highlighted {
+ bufferActivate(buffers[i].bufferName)
+ break
+ }
+ }
+ },
+ {
+ KeyName: fyne.KeyA,
+ Modifier: fyne.KeyModifierAlt,
+ }: func() {
+ for _, i := range rotatedBuffers() {
+ if buffers[i].newMessages != 0 {
+ bufferActivate(buffers[i].bufferName)
+ break
+ }
+ }
+ },
+ {
+ KeyName: fyne.KeyH,
+ Modifier: fyne.KeyModifierAlt | fyne.KeyModifierShift,
+ }: func() {
+ if b := bufferByName(bufferCurrent); b != nil {
+ bufferToggleUnimportant(b.bufferName)
+ }
+ },
+ {
+ KeyName: fyne.KeyH,
+ Modifier: fyne.KeyModifierAlt,
+ }: func() {
+ if b := bufferByName(bufferCurrent); b != nil {
+ bufferToggleLog()
+ }
+ },
+}
+
func main() {
flag.Usage = func() {
fmt.Fprintf(flag.CommandLine.Output(),
@@ -1071,18 +1511,26 @@ func main() {
os.Exit(1)
}
+ var err error
+ otoContext, otoReady, err = oto.NewContext(&oto.NewContextOptions{
+ SampleRate: 44100,
+ ChannelCount: 1,
+ Format: oto.FormatSignedInt16LE,
+ })
+ if err != nil {
+ log.Println(err)
+ }
+
a := app.New()
a.Settings().SetTheme(&customTheme{})
+ a.SetIcon(resourceIconNormal)
wWindow = a.NewWindow(projectName)
wWindow.Resize(fyne.NewSize(640, 480))
a.Lifecycle().SetOnEnteredForeground(func() {
// TODO(p): Does this need locking?
inForeground = true
- if b := bufferByName(bufferCurrent); b != nil {
- b.highlighted = false
- refreshIcon()
- }
+ recheckHighlighted()
})
a.Lifecycle().SetOnExitedForeground(func() {
inForeground = false
@@ -1119,60 +1567,84 @@ func main() {
wTopic = widget.NewRichText()
wTopic.Truncation = fyne.TextTruncateEllipsis
+
wRichText = widget.NewRichText()
wRichText.Wrapping = fyne.TextWrapWord
wRichScroll = container.NewVScroll(wRichText)
+ wRichScroll.OnScrolled = func(position fyne.Position) {
+ recheckHighlighted()
+ refreshStatus()
+ }
+ wLog = newLogEntry()
+ wLog.Wrapping = fyne.TextWrapWord
+ wLog.Hide()
+
wPrompt = widget.NewLabelWithStyle(
"", fyne.TextAlignLeading, fyne.TextStyle{Bold: true})
+ wDown = widget.NewIcon(theme.MoveDownIcon())
wStatus = widget.NewLabelWithStyle(
"", fyne.TextAlignTrailing, fyne.TextStyle{})
-
- wEntry = newCustomEntry()
- // TODO(p): Rather respond to all keypresses/similar activity.
- wEntry.OnChanged = func(text string) {
- relaySend(RelayCommandData{Variant: &RelayCommandDataActive{}}, nil)
- }
- wEntry.OnSubmitted = func(text string) {
- inputSubmit(text)
- }
+ wEntry = newInputEntry()
+ wEntry.OnSubmitted = func(text string) { inputSubmit(text) }
top := container.NewVBox(
wTopic,
widget.NewSeparator(),
)
+ split := container.NewHSplit(wBufferList,
+ container.New(&customLayout{}, wRichScroll, wLog))
+ split.SetOffset(0.25)
bottom := container.NewVBox(
widget.NewSeparator(),
- container.NewBorder(nil, nil, wPrompt, wStatus),
+ container.NewBorder(nil, nil,
+ wPrompt, container.NewHBox(wDown, wStatus)),
wEntry,
)
- split := container.NewHSplit(wBufferList, wRichScroll)
- split.SetOffset(0.25)
wWindow.SetContent(container.NewBorder(top, bottom, nil, nil, split))
+ canvas := wWindow.Canvas()
+ for s, handler := range shortcuts {
+ canvas.AddShortcut(&s, func(fyne.Shortcut) { handler() })
+ }
+
+ // ---
+
+ connect := false
+ backendAddress = a.Preferences().String(preferenceAddress)
+ if flag.NArg() >= 1 {
+ backendAddress = flag.Arg(0)
+ connect = true
+ }
+
connectAddress := widget.NewEntry()
connectAddress.SetPlaceHolder("host:port")
+ connectAddress.SetText(backendAddress)
+ connectAddress.TypedKey(&fyne.KeyEvent{Name: fyne.KeyPageDown})
connectAddress.Validator = func(text string) error {
_, _, err := net.SplitHostPort(text)
return err
}
- connectForm := dialog.NewForm("Connect to relay", "Connect", "Exit",
+
+ // TODO(p): Mobile should not have the option to cancel at all.
+ // The GoBack just makes us go to the background, staying useless.
+ wConnect = dialog.NewForm("Connect to relay", "Connect", "Exit",
[]*widget.FormItem{
{Text: "Address:", Widget: connectAddress},
}, func(ok bool) {
- if !ok {
- a.Quit()
- } else {
+ if ok {
backendAddress = connectAddress.Text
go relayRun()
+ } else if md, ok := a.Driver().(mobile.Driver); ok {
+ md.GoBack()
+ wConnect.Show()
+ } else {
+ a.Quit()
}
}, wWindow)
-
- if flag.NArg() >= 1 {
- backendAddress = flag.Arg(0)
- connectAddress.SetText(backendAddress)
+ if connect {
go relayRun()
} else {
- connectForm.Show()
+ wConnect.Show()
}
wWindow.ShowAndRun()
diff --git a/xC.c b/xC.c
index 2f6cf85..d79b600 100644
--- a/xC.c
+++ b/xC.c
@@ -474,6 +474,10 @@ input_rl_start (void *input, const char *program_name)
// autofilter, and we don't generally want alphabetic ordering at all
rl_sort_completion_matches = false;
+ // Readline >= 8.0 otherwise prints spurious newlines on EOF.
+ if (RL_VERSION_MAJOR >= 8)
+ rl_variable_bind ("enable-bracketed-paste", "off");
+
hard_assert (self->prompt != NULL);
// The inputrc is read before any callbacks are called, so we need to
// register all functions that our user may want to map up front
@@ -1814,6 +1818,7 @@ struct client
uint32_t event_seq; ///< Outgoing message counter
bool initialized; ///< Initial sync took place
+ bool closing; ///< We're closing the connection
struct poller_fd socket_event; ///< The socket can be read/written to
};
@@ -1871,7 +1876,7 @@ enum server_state
IRC_CONNECTED, ///< Trying to register
IRC_REGISTERED, ///< We can chat now
IRC_CLOSING, ///< Flushing output before shutdown
- IRC_HALF_CLOSED ///< Connection shutdown from our side
+ IRC_HALF_CLOSED ///< Connection shut down from our side
};
/// Convert an IRC identifier character to lower-case
@@ -2259,14 +2264,6 @@ struct app_context
struct str_map servers; ///< Our servers
- // Relay:
-
- int relay_fd; ///< Listening socket FD
- struct client *clients; ///< Our relay clients
-
- /// A single message buffer to prepare all outcoming messages within
- struct relay_event_message relay_message;
-
// Events:
struct poller_fd tty_event; ///< Terminal input event
@@ -2318,6 +2315,14 @@ struct app_context
char *editor_filename; ///< The file being edited by user
int terminal_suspended; ///< Terminal suspension level
+ // Relay:
+
+ int relay_fd; ///< Listening socket FD
+ struct client *clients; ///< Our relay clients
+
+ /// A single message buffer to prepare all outcoming messages within
+ struct relay_event_message relay_message;
+
// Plugins:
struct plugin *plugins; ///< Loaded plugins
@@ -2388,8 +2393,6 @@ app_context_init (struct app_context *self)
self->config = config_make ();
poller_init (&self->poller);
- self->relay_fd = -1;
-
self->servers = str_map_make ((str_map_free_fn) server_unref);
self->servers.key_xfrm = tolower_ascii_strxfrm;
@@ -2413,6 +2416,8 @@ app_context_init (struct app_context *self)
self->nick_palette =
filter_color_cube_for_acceptable_nick_colors (&self->nick_palette_len);
+
+ self->relay_fd = -1;
}
static void
@@ -2891,390 +2896,6 @@ serialize_configuration (struct config_item *root, struct str *output)
config_item_write (root, true, output);
}
-// --- Relay output ------------------------------------------------------------
-
-static void
-client_kill (struct client *c)
-{
- struct app_context *ctx = c->ctx;
- poller_fd_reset (&c->socket_event);
- xclose (c->socket_fd);
- c->socket_fd = -1;
-
- LIST_UNLINK (ctx->clients, c);
- client_destroy (c);
-}
-
-static void
-client_update_poller (struct client *c, const struct pollfd *pfd)
-{
- int new_events = POLLIN;
- if (c->write_buffer.len)
- new_events |= POLLOUT;
-
- hard_assert (new_events != 0);
- if (!pfd || pfd->events != new_events)
- poller_fd_set (&c->socket_event, new_events);
-}
-
-// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
-static void
-relay_send (struct client *c)
-{
- struct relay_event_message *m = &c->ctx->relay_message;
- m->event_seq = c->event_seq++;
-
- // TODO: Also don't try sending anything if half-closed.
- if (!c->initialized || c->socket_fd == -1)
- return;
-
- // liberty has msg_{reader,writer} already, but they use 8-byte lengths.
- size_t frame_len_pos = c->write_buffer.len, frame_len = 0;
- str_pack_u32 (&c->write_buffer, 0);
- if (!relay_event_message_serialize (m, &c->write_buffer)
- || (frame_len = c->write_buffer.len - frame_len_pos - 4) > UINT32_MAX)
- {
- print_error ("serialization failed, killing client");
- client_kill (c);
- return;
- }
-
- uint32_t len = htonl (frame_len);
- memcpy (c->write_buffer.str + frame_len_pos, &len, sizeof len);
- client_update_poller (c, NULL);
-}
-
-static void
-relay_broadcast_except (struct app_context *ctx, struct client *exception)
-{
- LIST_FOR_EACH (struct client, c, ctx->clients)
- if (c != exception)
- relay_send (c);
-}
-
-#define relay_broadcast(ctx) relay_broadcast_except ((ctx), NULL)
-
-static struct relay_event_message *
-relay_prepare (struct app_context *ctx)
-{
- struct relay_event_message *m = &ctx->relay_message;
- relay_event_message_free (m);
- memset (m, 0, sizeof *m);
- return m;
-}
-
-static void
-relay_prepare_ping (struct app_context *ctx)
-{
- relay_prepare (ctx)->data.event = RELAY_EVENT_PING;
-}
-
-static union relay_item_data *
-relay_translate_formatter (struct app_context *ctx, union relay_item_data *p,
- const struct formatter_item *i)
-{
- // XXX: See attr_printer_decode_color(), this is a footgun.
- int16_t c16 = i->color;
- int16_t c256 = i->color >> 16;
-
- unsigned attrs = i->attribute;
- switch (i->type)
- {
- case FORMATTER_ITEM_TEXT:
- p->text.text = str_from_cstr (i->text);
- (p++)->kind = RELAY_ITEM_TEXT;
- break;
- case FORMATTER_ITEM_FG_COLOR:
- p->fg_color.color = c256 <= 0 ? c16 : c256;
- (p++)->kind = RELAY_ITEM_FG_COLOR;
- break;
- case FORMATTER_ITEM_BG_COLOR:
- p->bg_color.color = c256 <= 0 ? c16 : c256;
- (p++)->kind = RELAY_ITEM_BG_COLOR;
- break;
- case FORMATTER_ITEM_ATTR:
- (p++)->kind = RELAY_ITEM_RESET;
- if ((c256 = ctx->theme[i->attribute].fg) >= 0)
- {
- p->fg_color.color = c256;
- (p++)->kind = RELAY_ITEM_FG_COLOR;
- }
- if ((c256 = ctx->theme[i->attribute].bg) >= 0)
- {
- p->bg_color.color = c256;
- (p++)->kind = RELAY_ITEM_BG_COLOR;
- }
-
- attrs = ctx->theme[i->attribute].attrs;
- // Fall-through
- case FORMATTER_ITEM_SIMPLE:
- if (attrs & TEXT_BOLD)
- (p++)->kind = RELAY_ITEM_FLIP_BOLD;
- if (attrs & TEXT_ITALIC)
- (p++)->kind = RELAY_ITEM_FLIP_ITALIC;
- if (attrs & TEXT_UNDERLINE)
- (p++)->kind = RELAY_ITEM_FLIP_UNDERLINE;
- if (attrs & TEXT_INVERSE)
- (p++)->kind = RELAY_ITEM_FLIP_INVERSE;
- if (attrs & TEXT_CROSSED_OUT)
- (p++)->kind = RELAY_ITEM_FLIP_CROSSED_OUT;
- if (attrs & TEXT_MONOSPACE)
- (p++)->kind = RELAY_ITEM_FLIP_MONOSPACE;
- break;
- default:
- break;
- }
- return p;
-}
-
-static union relay_item_data *
-relay_items (struct app_context *ctx, const struct formatter_item *items,
- uint32_t *len)
-{
- size_t items_len = 0;
- for (size_t i = 0; items[i].type; i++)
- items_len++;
-
- // Beware of the upper bound, currently dominated by FORMATTER_ITEM_ATTR.
- union relay_item_data *a = xcalloc (items_len * 9, sizeof *a), *p = a;
- for (const struct formatter_item *i = items; items_len--; i++)
- p = relay_translate_formatter (ctx, p, i);
-
- *len = p - a;
- return a;
-}
-
-static void
-relay_prepare_buffer_line (struct app_context *ctx, struct buffer *buffer,
- struct buffer_line *line, bool leak_to_active)
-{
- struct relay_event_message *m = relay_prepare (ctx);
- struct relay_event_data_buffer_line *e = &m->data.buffer_line;
- e->event = RELAY_EVENT_BUFFER_LINE;
- e->buffer_name = str_from_cstr (buffer->name);
- e->is_unimportant = !!(line->flags & BUFFER_LINE_UNIMPORTANT);
- e->is_highlight = !!(line->flags & BUFFER_LINE_HIGHLIGHT);
- e->rendition = 1 + line->r;
- e->when = line->when * 1000;
- e->leak_to_active = leak_to_active;
- e->items = relay_items (ctx, line->items, &e->items_len);
-}
-
-// TODO: Consider pushing this whole block of code much further down.
-static void formatter_add (struct formatter *self, const char *format, ...);
-static char *irc_to_utf8 (const char *text);
-
-static void
-relay_prepare_channel_buffer_update (struct app_context *ctx,
- struct buffer *buffer, struct relay_buffer_context_channel *e)
-{
- struct channel *channel = buffer->channel;
- struct formatter f = formatter_make (ctx, buffer->server);
- if (channel->topic)
- formatter_add (&f, "#m", channel->topic);
- e->topic = relay_items (ctx, f.items, &e->topic_len);
- formatter_free (&f);
-
- // As in make_prompt(), conceal the last known channel modes.
- // XXX: This should use irc_channel_is_joined().
- if (!channel->users_len)
- return;
-
- struct str modes = str_make ();
- str_append_str (&modes, &channel->no_param_modes);
-
- struct str params = str_make ();
- struct str_map_iter iter = str_map_iter_make (&channel->param_modes);
- const char *param;
- while ((param = str_map_iter_next (&iter)))
- {
- str_append_c (&modes, iter.link->key[0]);
- str_append_c (&params, ' ');
- str_append (&params, param);
- }
-
- str_append_str (&modes, &params);
- str_free (&params);
-
- char *modes_utf8 = irc_to_utf8 (modes.str);
- str_free (&modes);
- e->modes = str_from_cstr (modes_utf8);
- free (modes_utf8);
-}
-
-static void
-relay_prepare_buffer_update (struct app_context *ctx, struct buffer *buffer)
-{
- struct relay_event_message *m = relay_prepare (ctx);
- struct relay_event_data_buffer_update *e = &m->data.buffer_update;
- e->event = RELAY_EVENT_BUFFER_UPDATE;
- e->buffer_name = str_from_cstr (buffer->name);
- e->hide_unimportant = buffer->hide_unimportant;
-
- struct str *server_name = NULL;
- switch (buffer->type)
- {
- case BUFFER_GLOBAL:
- e->context.kind = RELAY_BUFFER_KIND_GLOBAL;
- break;
- case BUFFER_SERVER:
- e->context.kind = RELAY_BUFFER_KIND_SERVER;
- server_name = &e->context.server.server_name;
- break;
- case BUFFER_CHANNEL:
- e->context.kind = RELAY_BUFFER_KIND_CHANNEL;
- server_name = &e->context.channel.server_name;
- relay_prepare_channel_buffer_update (ctx, buffer, &e->context.channel);
- break;
- case BUFFER_PM:
- e->context.kind = RELAY_BUFFER_KIND_PRIVATE_MESSAGE;
- server_name = &e->context.private_message.server_name;
- break;
- }
- if (server_name)
- *server_name = str_from_cstr (buffer->server->name);
-}
-
-static void
-relay_prepare_buffer_stats (struct app_context *ctx, struct buffer *buffer)
-{
- struct relay_event_message *m = relay_prepare (ctx);
- struct relay_event_data_buffer_stats *e = &m->data.buffer_stats;
- e->event = RELAY_EVENT_BUFFER_STATS;
- e->buffer_name = str_from_cstr (buffer->name);
- e->new_messages = MIN (UINT32_MAX,
- buffer->new_messages_count - buffer->new_unimportant_count);
- e->new_unimportant_messages = MIN (UINT32_MAX,
- buffer->new_unimportant_count);
- e->highlighted = buffer->highlighted;
-}
-
-static void
-relay_prepare_buffer_rename (struct app_context *ctx, struct buffer *buffer,
- const char *new_name)
-{
- struct relay_event_message *m = relay_prepare (ctx);
- struct relay_event_data_buffer_rename *e = &m->data.buffer_rename;
- e->event = RELAY_EVENT_BUFFER_RENAME;
- e->buffer_name = str_from_cstr (buffer->name);
- e->new = str_from_cstr (new_name);
-}
-
-static void
-relay_prepare_buffer_remove (struct app_context *ctx, struct buffer *buffer)
-{
- struct relay_event_message *m = relay_prepare (ctx);
- struct relay_event_data_buffer_remove *e = &m->data.buffer_remove;
- e->event = RELAY_EVENT_BUFFER_REMOVE;
- e->buffer_name = str_from_cstr (buffer->name);
-}
-
-static void
-relay_prepare_buffer_activate (struct app_context *ctx, struct buffer *buffer)
-{
- struct relay_event_message *m = relay_prepare (ctx);
- struct relay_event_data_buffer_activate *e = &m->data.buffer_activate;
- e->event = RELAY_EVENT_BUFFER_ACTIVATE;
- e->buffer_name = str_from_cstr (buffer->name);
-}
-
-static void
-relay_prepare_buffer_input (struct app_context *ctx, struct buffer *buffer,
- const char *input)
-{
- struct relay_event_message *m = relay_prepare (ctx);
- struct relay_event_data_buffer_input *e = &m->data.buffer_input;
- e->event = RELAY_EVENT_BUFFER_INPUT;
- e->buffer_name = str_from_cstr (buffer->name);
- e->text = str_from_cstr (input);
-}
-
-static void
-relay_prepare_buffer_clear (struct app_context *ctx,
- struct buffer *buffer)
-{
- struct relay_event_message *m = relay_prepare (ctx);
- struct relay_event_data_buffer_clear *e = &m->data.buffer_clear;
- e->event = RELAY_EVENT_BUFFER_CLEAR;
- e->buffer_name = str_from_cstr (buffer->name);
-}
-
-enum relay_server_state
-relay_server_state_for_server (struct server *s)
-{
- switch (s->state)
- {
- case IRC_DISCONNECTED: return RELAY_SERVER_STATE_DISCONNECTED;
- case IRC_CONNECTING: return RELAY_SERVER_STATE_CONNECTING;
- case IRC_CONNECTED: return RELAY_SERVER_STATE_CONNECTED;
- case IRC_REGISTERED: return RELAY_SERVER_STATE_REGISTERED;
- case IRC_CLOSING:
- case IRC_HALF_CLOSED: return RELAY_SERVER_STATE_DISCONNECTING;
- }
- return 0;
-}
-
-static void
-relay_prepare_server_update (struct app_context *ctx, struct server *s)
-{
- struct relay_event_message *m = relay_prepare (ctx);
- struct relay_event_data_server_update *e = &m->data.server_update;
- e->event = RELAY_EVENT_SERVER_UPDATE;
- e->server_name = str_from_cstr (s->name);
- e->data.state = relay_server_state_for_server (s);
- if (s->state == IRC_REGISTERED)
- {
- char *user_utf8 = irc_to_utf8 (s->irc_user->nickname);
- e->data.registered.user = str_from_cstr (user_utf8);
- free (user_utf8);
-
- char *user_modes_utf8 = irc_to_utf8 (s->irc_user_modes.str);
- e->data.registered.user_modes = str_from_cstr (user_modes_utf8);
- free (user_modes_utf8);
- }
-}
-
-static void
-relay_prepare_server_rename (struct app_context *ctx, struct server *s,
- const char *new_name)
-{
- struct relay_event_message *m = relay_prepare (ctx);
- struct relay_event_data_server_rename *e = &m->data.server_rename;
- e->event = RELAY_EVENT_SERVER_RENAME;
- e->server_name = str_from_cstr (s->name);
- e->new = str_from_cstr (new_name);
-}
-
-static void
-relay_prepare_server_remove (struct app_context *ctx, struct server *s)
-{
- struct relay_event_message *m = relay_prepare (ctx);
- struct relay_event_data_server_remove *e = &m->data.server_remove;
- e->event = RELAY_EVENT_SERVER_REMOVE;
- e->server_name = str_from_cstr (s->name);
-}
-
-static void
-relay_prepare_error (struct app_context *ctx, uint32_t seq, const char *message)
-{
- struct relay_event_message *m = relay_prepare (ctx);
- struct relay_event_data_error *e = &m->data.error;
- e->event = RELAY_EVENT_ERROR;
- e->command_seq = seq;
- e->error = str_from_cstr (message);
-}
-
-static struct relay_event_data_response *
-relay_prepare_response (struct app_context *ctx, uint32_t seq)
-{
- struct relay_event_message *m = relay_prepare (ctx);
- struct relay_event_data_response *e = &m->data.response;
- e->event = RELAY_EVENT_RESPONSE;
- e->command_seq = seq;
- return e;
-}
-
// --- Terminal output ---------------------------------------------------------
/// Default colour pair
@@ -4515,6 +4136,394 @@ formatter_flush (struct formatter *self, FILE *stream, int flush_opts)
attr_printer_reset (&state);
}
+// --- Relay output ------------------------------------------------------------
+
+static void
+client_kill (struct client *c)
+{
+ struct app_context *ctx = c->ctx;
+ poller_fd_reset (&c->socket_event);
+ xclose (c->socket_fd);
+ c->socket_fd = -1;
+
+ LIST_UNLINK (ctx->clients, c);
+ client_destroy (c);
+}
+
+static void
+client_update_poller (struct client *c, const struct pollfd *pfd)
+{
+ // In case of closing without any data in the write buffer,
+ // we don't actually need to be able to write to the socket,
+ // but the condition should be quick to satisfy.
+ int new_events = POLLIN;
+ if (c->write_buffer.len || c->closing)
+ new_events |= POLLOUT;
+
+ hard_assert (new_events != 0);
+ if (!pfd || pfd->events != new_events)
+ poller_fd_set (&c->socket_event, new_events);
+}
+
+// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+
+static void
+relay_send (struct client *c)
+{
+ struct relay_event_message *m = &c->ctx->relay_message;
+ m->event_seq = c->event_seq++;
+ if (!c->initialized || c->closing || c->socket_fd == -1)
+ return;
+
+ // liberty has msg_{reader,writer} already, but they use 8-byte lengths.
+ size_t frame_len_pos = c->write_buffer.len, frame_len = 0;
+ str_pack_u32 (&c->write_buffer, 0);
+ if (!relay_event_message_serialize (m, &c->write_buffer)
+ || (frame_len = c->write_buffer.len - frame_len_pos - 4) > UINT32_MAX)
+ {
+ print_error ("serialization failed, killing client");
+
+ // We can't kill the client immediately,
+ // because more relay_send() calls may follow.
+ c->write_buffer.len = frame_len_pos;
+ c->closing = true;
+ }
+ else
+ {
+ uint32_t len = htonl (frame_len);
+ memcpy (c->write_buffer.str + frame_len_pos, &len, sizeof len);
+ }
+
+ client_update_poller (c, NULL);
+}
+
+static void
+relay_broadcast_except (struct app_context *ctx, struct client *exception)
+{
+ LIST_FOR_EACH (struct client, c, ctx->clients)
+ if (c != exception)
+ relay_send (c);
+}
+
+#define relay_broadcast(ctx) relay_broadcast_except ((ctx), NULL)
+
+static struct relay_event_message *
+relay_prepare (struct app_context *ctx)
+{
+ struct relay_event_message *m = &ctx->relay_message;
+ relay_event_message_free (m);
+ memset (m, 0, sizeof *m);
+ return m;
+}
+
+static void
+relay_prepare_ping (struct app_context *ctx)
+{
+ relay_prepare (ctx)->data.event = RELAY_EVENT_PING;
+}
+
+static union relay_item_data *
+relay_translate_formatter (struct app_context *ctx, union relay_item_data *p,
+ const struct formatter_item *i)
+{
+ // XXX: See attr_printer_decode_color(), this is a footgun.
+ int16_t c16 = i->color;
+ int16_t c256 = i->color >> 16;
+
+ unsigned attrs = i->attribute;
+ switch (i->type)
+ {
+ case FORMATTER_ITEM_TEXT:
+ p->text.text = str_from_cstr (i->text);
+ (p++)->kind = RELAY_ITEM_TEXT;
+ break;
+ case FORMATTER_ITEM_FG_COLOR:
+ p->fg_color.color = c256 <= 0 ? c16 : c256;
+ (p++)->kind = RELAY_ITEM_FG_COLOR;
+ break;
+ case FORMATTER_ITEM_BG_COLOR:
+ p->bg_color.color = c256 <= 0 ? c16 : c256;
+ (p++)->kind = RELAY_ITEM_BG_COLOR;
+ break;
+ case FORMATTER_ITEM_ATTR:
+ (p++)->kind = RELAY_ITEM_RESET;
+ if ((c256 = ctx->theme[i->attribute].fg) >= 0)
+ {
+ p->fg_color.color = c256;
+ (p++)->kind = RELAY_ITEM_FG_COLOR;
+ }
+ if ((c256 = ctx->theme[i->attribute].bg) >= 0)
+ {
+ p->bg_color.color = c256;
+ (p++)->kind = RELAY_ITEM_BG_COLOR;
+ }
+
+ attrs = ctx->theme[i->attribute].attrs;
+ // Fall-through
+ case FORMATTER_ITEM_SIMPLE:
+ if (attrs & TEXT_BOLD)
+ (p++)->kind = RELAY_ITEM_FLIP_BOLD;
+ if (attrs & TEXT_ITALIC)
+ (p++)->kind = RELAY_ITEM_FLIP_ITALIC;
+ if (attrs & TEXT_UNDERLINE)
+ (p++)->kind = RELAY_ITEM_FLIP_UNDERLINE;
+ if (attrs & TEXT_INVERSE)
+ (p++)->kind = RELAY_ITEM_FLIP_INVERSE;
+ if (attrs & TEXT_CROSSED_OUT)
+ (p++)->kind = RELAY_ITEM_FLIP_CROSSED_OUT;
+ if (attrs & TEXT_MONOSPACE)
+ (p++)->kind = RELAY_ITEM_FLIP_MONOSPACE;
+ break;
+ default:
+ break;
+ }
+ return p;
+}
+
+static union relay_item_data *
+relay_items (struct app_context *ctx, const struct formatter_item *items,
+ uint32_t *len)
+{
+ size_t items_len = 0;
+ for (size_t i = 0; items[i].type; i++)
+ items_len++;
+
+ // Beware of the upper bound, currently dominated by FORMATTER_ITEM_ATTR.
+ union relay_item_data *a = xcalloc (items_len * 9, sizeof *a), *p = a;
+ for (const struct formatter_item *i = items; items_len--; i++)
+ p = relay_translate_formatter (ctx, p, i);
+
+ *len = p - a;
+ return a;
+}
+
+static void
+relay_prepare_buffer_line (struct app_context *ctx, struct buffer *buffer,
+ struct buffer_line *line, bool leak_to_active)
+{
+ struct relay_event_message *m = relay_prepare (ctx);
+ struct relay_event_data_buffer_line *e = &m->data.buffer_line;
+ e->event = RELAY_EVENT_BUFFER_LINE;
+ e->buffer_name = str_from_cstr (buffer->name);
+ e->is_unimportant = !!(line->flags & BUFFER_LINE_UNIMPORTANT);
+ e->is_highlight = !!(line->flags & BUFFER_LINE_HIGHLIGHT);
+ e->rendition = 1 + line->r;
+ e->when = line->when * 1000;
+ e->leak_to_active = leak_to_active;
+ e->items = relay_items (ctx, line->items, &e->items_len);
+}
+
+static void
+relay_prepare_channel_buffer_update (struct app_context *ctx,
+ struct buffer *buffer, struct relay_buffer_context_channel *e)
+{
+ struct channel *channel = buffer->channel;
+ struct formatter f = formatter_make (ctx, buffer->server);
+ if (channel->topic)
+ formatter_add (&f, "#m", channel->topic);
+ FORMATTER_ADD_ITEM (&f, END);
+ e->topic = relay_items (ctx, f.items, &e->topic_len);
+ formatter_free (&f);
+
+ // As in make_prompt(), conceal the last known channel modes.
+ // XXX: This should use irc_channel_is_joined().
+ if (!channel->users_len)
+ return;
+
+ struct str modes = str_make ();
+ str_append_str (&modes, &channel->no_param_modes);
+
+ struct str params = str_make ();
+ struct str_map_iter iter = str_map_iter_make (&channel->param_modes);
+ const char *param;
+ while ((param = str_map_iter_next (&iter)))
+ {
+ str_append_c (&modes, iter.link->key[0]);
+ str_append_c (&params, ' ');
+ str_append (&params, param);
+ }
+
+ str_append_str (&modes, &params);
+ str_free (&params);
+
+ char *modes_utf8 = irc_to_utf8 (modes.str);
+ str_free (&modes);
+ e->modes = str_from_cstr (modes_utf8);
+ free (modes_utf8);
+}
+
+static void
+relay_prepare_buffer_update (struct app_context *ctx, struct buffer *buffer)
+{
+ struct relay_event_message *m = relay_prepare (ctx);
+ struct relay_event_data_buffer_update *e = &m->data.buffer_update;
+ e->event = RELAY_EVENT_BUFFER_UPDATE;
+ e->buffer_name = str_from_cstr (buffer->name);
+ e->hide_unimportant = buffer->hide_unimportant;
+
+ struct str *server_name = NULL;
+ switch (buffer->type)
+ {
+ case BUFFER_GLOBAL:
+ e->context.kind = RELAY_BUFFER_KIND_GLOBAL;
+ break;
+ case BUFFER_SERVER:
+ e->context.kind = RELAY_BUFFER_KIND_SERVER;
+ server_name = &e->context.server.server_name;
+ break;
+ case BUFFER_CHANNEL:
+ e->context.kind = RELAY_BUFFER_KIND_CHANNEL;
+ server_name = &e->context.channel.server_name;
+ relay_prepare_channel_buffer_update (ctx, buffer, &e->context.channel);
+ break;
+ case BUFFER_PM:
+ e->context.kind = RELAY_BUFFER_KIND_PRIVATE_MESSAGE;
+ server_name = &e->context.private_message.server_name;
+ break;
+ }
+ if (server_name)
+ *server_name = str_from_cstr (buffer->server->name);
+}
+
+static void
+relay_prepare_buffer_stats (struct app_context *ctx, struct buffer *buffer)
+{
+ struct relay_event_message *m = relay_prepare (ctx);
+ struct relay_event_data_buffer_stats *e = &m->data.buffer_stats;
+ e->event = RELAY_EVENT_BUFFER_STATS;
+ e->buffer_name = str_from_cstr (buffer->name);
+ e->new_messages = MIN (UINT32_MAX,
+ buffer->new_messages_count - buffer->new_unimportant_count);
+ e->new_unimportant_messages = MIN (UINT32_MAX,
+ buffer->new_unimportant_count);
+ e->highlighted = buffer->highlighted;
+}
+
+static void
+relay_prepare_buffer_rename (struct app_context *ctx, struct buffer *buffer,
+ const char *new_name)
+{
+ struct relay_event_message *m = relay_prepare (ctx);
+ struct relay_event_data_buffer_rename *e = &m->data.buffer_rename;
+ e->event = RELAY_EVENT_BUFFER_RENAME;
+ e->buffer_name = str_from_cstr (buffer->name);
+ e->new = str_from_cstr (new_name);
+}
+
+static void
+relay_prepare_buffer_remove (struct app_context *ctx, struct buffer *buffer)
+{
+ struct relay_event_message *m = relay_prepare (ctx);
+ struct relay_event_data_buffer_remove *e = &m->data.buffer_remove;
+ e->event = RELAY_EVENT_BUFFER_REMOVE;
+ e->buffer_name = str_from_cstr (buffer->name);
+}
+
+static void
+relay_prepare_buffer_activate (struct app_context *ctx, struct buffer *buffer)
+{
+ struct relay_event_message *m = relay_prepare (ctx);
+ struct relay_event_data_buffer_activate *e = &m->data.buffer_activate;
+ e->event = RELAY_EVENT_BUFFER_ACTIVATE;
+ e->buffer_name = str_from_cstr (buffer->name);
+}
+
+static void
+relay_prepare_buffer_input (struct app_context *ctx, struct buffer *buffer,
+ const char *input)
+{
+ struct relay_event_message *m = relay_prepare (ctx);
+ struct relay_event_data_buffer_input *e = &m->data.buffer_input;
+ e->event = RELAY_EVENT_BUFFER_INPUT;
+ e->buffer_name = str_from_cstr (buffer->name);
+ e->text = str_from_cstr (input);
+}
+
+static void
+relay_prepare_buffer_clear (struct app_context *ctx,
+ struct buffer *buffer)
+{
+ struct relay_event_message *m = relay_prepare (ctx);
+ struct relay_event_data_buffer_clear *e = &m->data.buffer_clear;
+ e->event = RELAY_EVENT_BUFFER_CLEAR;
+ e->buffer_name = str_from_cstr (buffer->name);
+}
+
+enum relay_server_state
+relay_server_state_for_server (struct server *s)
+{
+ switch (s->state)
+ {
+ case IRC_DISCONNECTED: return RELAY_SERVER_STATE_DISCONNECTED;
+ case IRC_CONNECTING: return RELAY_SERVER_STATE_CONNECTING;
+ case IRC_CONNECTED: return RELAY_SERVER_STATE_CONNECTED;
+ case IRC_REGISTERED: return RELAY_SERVER_STATE_REGISTERED;
+ case IRC_CLOSING:
+ case IRC_HALF_CLOSED: return RELAY_SERVER_STATE_DISCONNECTING;
+ }
+ return 0;
+}
+
+static void
+relay_prepare_server_update (struct app_context *ctx, struct server *s)
+{
+ struct relay_event_message *m = relay_prepare (ctx);
+ struct relay_event_data_server_update *e = &m->data.server_update;
+ e->event = RELAY_EVENT_SERVER_UPDATE;
+ e->server_name = str_from_cstr (s->name);
+ e->data.state = relay_server_state_for_server (s);
+ if (s->state == IRC_REGISTERED)
+ {
+ char *user_utf8 = irc_to_utf8 (s->irc_user->nickname);
+ e->data.registered.user = str_from_cstr (user_utf8);
+ free (user_utf8);
+
+ char *user_modes_utf8 = irc_to_utf8 (s->irc_user_modes.str);
+ e->data.registered.user_modes = str_from_cstr (user_modes_utf8);
+ free (user_modes_utf8);
+ }
+}
+
+static void
+relay_prepare_server_rename (struct app_context *ctx, struct server *s,
+ const char *new_name)
+{
+ struct relay_event_message *m = relay_prepare (ctx);
+ struct relay_event_data_server_rename *e = &m->data.server_rename;
+ e->event = RELAY_EVENT_SERVER_RENAME;
+ e->server_name = str_from_cstr (s->name);
+ e->new = str_from_cstr (new_name);
+}
+
+static void
+relay_prepare_server_remove (struct app_context *ctx, struct server *s)
+{
+ struct relay_event_message *m = relay_prepare (ctx);
+ struct relay_event_data_server_remove *e = &m->data.server_remove;
+ e->event = RELAY_EVENT_SERVER_REMOVE;
+ e->server_name = str_from_cstr (s->name);
+}
+
+static void
+relay_prepare_error (struct app_context *ctx, uint32_t seq, const char *message)
+{
+ struct relay_event_message *m = relay_prepare (ctx);
+ struct relay_event_data_error *e = &m->data.error;
+ e->event = RELAY_EVENT_ERROR;
+ e->command_seq = seq;
+ e->error = str_from_cstr (message);
+}
+
+static struct relay_event_data_response *
+relay_prepare_response (struct app_context *ctx, uint32_t seq)
+{
+ struct relay_event_message *m = relay_prepare (ctx);
+ struct relay_event_data_response *e = &m->data.response;
+ e->event = RELAY_EVENT_RESPONSE;
+ e->command_seq = seq;
+ return e;
+}
+
// --- Buffers -----------------------------------------------------------------
static void
@@ -7033,9 +7042,7 @@ irc_is_highlight (struct server *s, const char *message)
cstr_transform (nick, s->irc_tolower);
// Special characters allowed in nicknames by RFC 2812: []\`_^{|} and -
- // Also excluded from the ASCII: common user channel prefixes: +%@&~
- // XXX: why did I exclude those? It won't match when IRC newbies use them.
- const char *delimiters = ",.;:!?()<>/=#$* \t\r\n\v\f\"'";
+ const char *delimiters = "\t\n\v\f\r !\"#$%&'()*+,./:;<=>?@~";
bool result = false;
char *save = NULL;
@@ -14603,21 +14610,23 @@ on_readline_input (char *line)
if (*line)
add_history (line);
- // readline always erases the input line after returning from here,
+ // Readline always erases the input line after returning from here,
// but we don't want that to happen if the command to be executed
// would switch the buffer (we'd keep the already executed command in
// the old buffer and delete any input restored from the new buffer)
strv_append_owned (&ctx->pending_input, line);
poller_idle_set (&ctx->input_event);
}
- else
+ else if (isatty (STDIN_FILENO))
{
- // Prevent readline from showing the prompt twice for w/e reason
+ // Prevent Readline from showing the prompt twice for w/e reason
CALL (ctx->input, hide);
input_rl__restore (self);
CALL (ctx->input, ding);
}
+ else
+ request_quit (ctx, NULL);
if (self->active)
// Readline automatically redisplays it
@@ -15715,28 +15724,31 @@ client_process_message (struct client *c,
return true;
}
+ bool acknowledge = true;
switch (m->data.command)
{
case RELAY_COMMAND_HELLO:
+ c->initialized = true;
if (m->data.hello.version != RELAY_VERSION)
{
- // TODO: This should send back an error message and shut down.
log_global_error (c->ctx,
"Protocol version mismatch, killing client");
- return false;
+ relay_prepare_error (c->ctx,
+ m->command_seq, "Protocol version mismatch");
+ relay_send (c);
+
+ c->closing = true;
+ return true;
}
- c->initialized = true;
client_resync (c);
break;
case RELAY_COMMAND_PING:
- relay_prepare_response (c->ctx, m->command_seq)
- ->data.command = RELAY_COMMAND_PING;
- relay_send (c);
break;
case RELAY_COMMAND_ACTIVE:
reset_autoaway (c->ctx);
break;
case RELAY_COMMAND_BUFFER_COMPLETE:
+ acknowledge = false;
client_process_buffer_complete (c, m->command_seq, buffer,
&m->data.buffer_complete);
break;
@@ -15750,13 +15762,21 @@ client_process_message (struct client *c,
buffer_toggle_unimportant (c->ctx, buffer);
break;
case RELAY_COMMAND_BUFFER_LOG:
+ acknowledge = false;
client_process_buffer_log (c, m->command_seq, buffer);
break;
default:
+ acknowledge = false;
log_global_debug (c->ctx, "Unhandled client command");
relay_prepare_error (c->ctx, m->command_seq, "Unknown command");
relay_send (c);
}
+ if (acknowledge)
+ {
+ relay_prepare_response (c->ctx, m->command_seq)
+ ->data.command = m->data.command;
+ relay_send (c);
+ }
return true;
}
@@ -15778,7 +15798,7 @@ client_process_buffer (struct client *c)
break;
struct relay_command_message m = {};
- bool ok = client_process_message (c, &r, &m);
+ bool ok = c->closing || client_process_message (c, &r, &m);
relay_command_message_free (&m);
if (!ok)
return false;
@@ -15850,7 +15870,11 @@ on_client_ready (const struct pollfd *pfd, void *user_data)
{
struct client *c = user_data;
if (client_try_read (c) && client_try_write (c))
+ {
client_update_poller (c, pfd);
+ if (c->closing && !c->write_buffer.len)
+ client_kill (c);
+ }
}
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
diff --git a/xC.lxdr b/xC.lxdr
index af0f170..eba914f 100644
--- a/xC.lxdr
+++ b/xC.lxdr
@@ -1,7 +1,8 @@
// Backwards-compatible protocol version.
-const VERSION = 1;
+const VERSION = 2;
// From the frontend to the relay.
+// All commands receive either an Event.RESPONSE, or an Event.ERROR.
struct CommandMessage {
// The command sequence number will be repeated in responses
// in the respective fields.
@@ -32,13 +33,10 @@ struct CommandMessage {
// XXX: Perhaps this should rather be handled through a /buffer command.
case BUFFER_TOGGLE_UNIMPORTANT:
string buffer_name;
- case PING_RESPONSE:
- u32 event_seq;
-
- // Only these commands may produce Event.RESPONSE, as below,
- // but any command may produce an error.
case PING:
void;
+ case PING_RESPONSE:
+ u32 event_seq;
case BUFFER_COMPLETE:
string buffer_name;
string text;
@@ -52,6 +50,9 @@ struct CommandMessage {
struct EventMessage {
u32 event_seq;
union EventData switch (enum Event {
+ ERROR,
+ RESPONSE,
+
PING,
BUFFER_LINE,
BUFFER_UPDATE,
@@ -64,12 +65,28 @@ struct EventMessage {
SERVER_UPDATE,
SERVER_RENAME,
SERVER_REMOVE,
- ERROR,
- RESPONSE,
} event) {
+ // Restriction: command_seq strictly follows the sequence received
+ // by the relay, across both of these replies.
+ case ERROR:
+ u32 command_seq;
+ string error;
+ case RESPONSE:
+ u32 command_seq;
+ union ResponseData switch (Command command) {
+ case BUFFER_COMPLETE:
+ u32 start;
+ string completions<>;
+ case BUFFER_LOG:
+ // UTF-8, but not guaranteed.
+ u8 log<>;
+ default:
+ // Reception acknowledged.
+ void;
+ } data;
+
case PING:
void;
-
case BUFFER_LINE:
string buffer_name;
// Whether the line should also be displayed in the active buffer.
@@ -188,23 +205,5 @@ struct EventMessage {
string new;
case SERVER_REMOVE:
string server_name;
-
- // Restriction: command_seq strictly follows the sequence received
- // by the relay, across both of these replies.
- case ERROR:
- u32 command_seq;
- string error;
- case RESPONSE:
- u32 command_seq;
- union ResponseData switch (Command command) {
- case PING:
- void;
- case BUFFER_COMPLETE:
- u32 start;
- string completions<>;
- case BUFFER_LOG:
- // UTF-8, but not guaranteed.
- u8 log<>;
- } data;
} data;
};
diff --git a/xK-version b/xK-version
index 227cea2..7ec1d6d 100644
--- a/xK-version
+++ b/xK-version
@@ -1 +1 @@
-2.0.0
+2.1.0
diff --git a/xM/gen-icon.swift b/xM/gen-icon.swift
index 712e9e6..0f7477c 100644
--- a/xM/gen-icon.swift
+++ b/xM/gen-icon.swift
@@ -3,6 +3,9 @@
// Copyright (c) 2023, Přemysl Eric Janouch <p@janouch.name>
// SPDX-License-Identifier: 0BSD
//
+// As an odd regression, AppKit may be necessary for JIT linking.
+import AppKit
+
// NSGraphicsContext mostly just weirdly wraps over Quartz,
// so we do it all in Quartz directly.
import CoreGraphics
diff --git a/xM/main.swift b/xM/main.swift
index 91e3499..39b66c5 100644
--- a/xM/main.swift
+++ b/xM/main.swift
@@ -173,8 +173,11 @@ class RelayRPC {
func send(data: RelayCommandData, callback: Callback? = nil) {
self.commandSeq += 1
let m = RelayCommandMessage(commandSeq: self.commandSeq, data: data)
- if let callback = callback {
- self.commandCallbacks[m.commandSeq] = callback
+ self.commandCallbacks[m.commandSeq] = callback ?? { error, data in
+ if data == nil {
+ NSSound.beep()
+ Logger().warning("\(error)")
+ }
}
var w = RelayWriter()
@@ -842,11 +845,11 @@ relayRPC.onEvent = { message in
b.bufferName = data.new
- refreshBufferList()
if b.bufferName == relayBufferCurrent {
relayBufferCurrent = data.new
refreshStatus()
}
+ refreshBufferList()
if b.bufferName == relayBufferLast {
relayBufferLast = data.new
}
@@ -1203,6 +1206,7 @@ class WindowDelegate: NSObject, NSWindowDelegate {
b.highlighted = false
refreshIcon()
+ refreshBufferList()
}
// Buffer indexes rotated to start after the current buffer.
diff --git a/xN/xN.go b/xN/xN.go
index bdec3dd..20f36c7 100644
--- a/xN/xN.go
+++ b/xN/xN.go
@@ -247,16 +247,16 @@ func main() {
flag.PrintDefaults()
}
flag.Parse()
- if flag.NArg() < 1 {
- flag.Usage()
- os.Exit(2)
- }
-
if *version {
fmt.Printf("%s %s\n", projectName, projectVersion)
return
}
+ if flag.NArg() < 1 {
+ flag.Usage()
+ os.Exit(2)
+ }
+
text, err := io.ReadAll(os.Stdin)
if err != nil {
log.Fatalln(err)
diff --git a/xP/go.mod b/xP/go.mod
index 70cc10d..dca4d10 100644
--- a/xP/go.mod
+++ b/xP/go.mod
@@ -1,10 +1,7 @@
module janouch.name/xK/xP
-go 1.18
+go 1.21
-require nhooyr.io/websocket v1.8.7
+toolchain go1.23.2
-require (
- github.com/klauspost/compress v1.15.9 // indirect
- golang.org/x/sys v0.0.0-20210423082822-04245dca01da // indirect
-)
+require nhooyr.io/websocket v1.8.17
diff --git a/xP/go.sum b/xP/go.sum
index c94a673..9c3072b 100644
--- a/xP/go.sum
+++ b/xP/go.sum
@@ -1,62 +1,2 @@
-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=
+nhooyr.io/websocket v1.8.17 h1:KEVeLJkUywCKVsnLIDlD/5gtayKp8VoCkksHCGGfT9Y=
+nhooyr.io/websocket v1.8.17/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c=
diff --git a/xP/public/xP.js b/xP/public/xP.js
index 5436a65..33d7d2a 100644
--- a/xP/public/xP.js
+++ b/xP/public/xP.js
@@ -1,4 +1,4 @@
-// Copyright (c) 2022 - 2023, Přemysl Eric Janouch <p@janouch.name>
+// Copyright (c) 2022 - 2025, Přemysl Eric Janouch <p@janouch.name>
// SPDX-License-Identifier: 0BSD
import * as Relay from './proto.js'
@@ -67,18 +67,19 @@ class RelayRPC extends EventTarget {
_processOne(message) {
let e = message.data
+ let p
switch (e.event) {
case Relay.Event.Error:
- if (this.promised[e.commandSeq] !== undefined)
- this.promised[e.commandSeq].reject(e.error)
- else
+ if ((p = this.promised[e.commandSeq]) === undefined)
console.error(`Unawaited error: ${e.error}`)
+ else if (p !== true)
+ p.reject(e.error)
break
case Relay.Event.Response:
- if (this.promised[e.commandSeq] !== undefined)
- this.promised[e.commandSeq].resolve(e.data)
- else
+ if ((p = this.promised[e.commandSeq]) === undefined)
console.error("Unawaited response")
+ else if (p !== true)
+ p.resolve(e.data)
break
default:
e.eventSeq = message.eventSeq
@@ -95,6 +96,13 @@ class RelayRPC extends EventTarget {
this.promised[seq].reject("No response")
delete this.promised[seq]
}
+ m.redraw()
+ }
+
+ get busy() {
+ for (const seq in this.promised)
+ return true
+ return false
}
send(params) {
@@ -110,6 +118,9 @@ class RelayRPC extends EventTarget {
this.ws.send(JSON.stringify({commandSeq: seq, data: params}))
+ this.promised[seq] = true
+ m.redraw()
+
// Automagically detect if we want a result.
let data = undefined
const promise = new Promise(
@@ -191,6 +202,17 @@ let bufferAutoscroll = true
let servers = new Map()
+let lastActive = undefined
+
+function notifyActive() {
+ // Reduce unnecessary traffic.
+ const now = Date.now()
+ if (lastActive === undefined || (now - lastActive >= 5000)) {
+ lastActive = now
+ rpc.send({command: 'Active'})
+ }
+}
+
function bufferResetStats(b) {
b.newMessages = 0
b.newUnimportantMessages = 0
@@ -998,7 +1020,7 @@ let Input = {
onKeyDown: event => {
// TODO: And perhaps on other actions, too.
- rpc.send({command: 'Active'})
+ notifyActive()
let b = buffers.get(bufferCurrent)
if (b === undefined || event.isComposing)
@@ -1103,7 +1125,13 @@ let Main = {
return m('.xP', {}, [
overlay,
- m('.title', {}, [m('b', {}, `xP`), m(Topic)]),
+ m('.title', {}, [
+ m('span', [
+ rpc.busy ? '⋯ ' : undefined,
+ m('b', {}, `xP`),
+ ]),
+ m(Topic),
+ ]),
m('.middle', {}, [m(BufferList), m(BufferContainer)]),
m(Status),
m('.input', {}, [m(Prompt), m(Input)]),
diff --git a/xR/.gitignore b/xR/.gitignore
new file mode 100644
index 0000000..a9766d8
--- /dev/null
+++ b/xR/.gitignore
@@ -0,0 +1,2 @@
+/xR
+/proto.go
diff --git a/xR/Makefile b/xR/Makefile
new file mode 100644
index 0000000..7fb55c5
--- /dev/null
+++ b/xR/Makefile
@@ -0,0 +1,17 @@
+.POSIX:
+AWK = env LC_ALL=C awk
+
+tools = ../liberty/tools
+generated = proto.go
+outputs = xR $(generated)
+all: $(outputs)
+generate: $(generated)
+
+proto.go: $(tools)/lxdrgen.awk $(tools)/lxdrgen-go.awk ../xC.lxdr
+ $(AWK) -f $(tools)/lxdrgen.awk -f $(tools)/lxdrgen-go.awk \
+ -v PrefixCamel=Relay ../xC.lxdr > $@
+xR: xR.go ../xK-version $(generated)
+ go build -ldflags "-X 'main.projectVersion=$$(cat ../xK-version)'" -o $@ \
+ -gcflags=all="-N -l"
+clean:
+ rm -f $(outputs)
diff --git a/xR/go.mod b/xR/go.mod
new file mode 100644
index 0000000..998a18f
--- /dev/null
+++ b/xR/go.mod
@@ -0,0 +1,5 @@
+module janouch.name/xK/xR
+
+go 1.23.0
+
+toolchain go1.24.0
diff --git a/xR/xR.adoc b/xR/xR.adoc
new file mode 100644
index 0000000..c3215bd
--- /dev/null
+++ b/xR/xR.adoc
@@ -0,0 +1,41 @@
+xR(1)
+=====
+:doctype: manpage
+:manmanual: xK Manual
+:mansource: xK {release-version}
+
+Name
+----
+xR - xC relay protocol analyzer
+
+Synopsis
+--------
+*xR* [_OPTION_]... RELAY-ADDRESS...
+
+Description
+-----------
+*xR* connects to an *xC* relay and prints all incoming events one per line
+in JSON format. The JSON objects have two additional fields:
+
+when::
+ The time of reception (or sending) as a nanosecond precision
+ RFC 3339 UTC timestamp.
+raw::
+ The incoming event (or outgoing command) in raw binary form.
+
+Options
+-------
+*-debug*::
+ Print any outgoing commands as well, which may help in debugging any issues.
+
+*-version*::
+ Output version information and exit.
+
+Reporting bugs
+--------------
+Use https://git.janouch.name/p/xK to report bugs, request features,
+or submit pull requests.
+
+See also
+--------
+*xC*(1)
diff --git a/xR/xR.go b/xR/xR.go
new file mode 100644
index 0000000..a26832d
--- /dev/null
+++ b/xR/xR.go
@@ -0,0 +1,134 @@
+// Copyright (c) 2025, Přemysl Eric Janouch <p@janouch.name>
+// SPDX-License-Identifier: 0BSD
+
+package main
+
+import (
+ "encoding/binary"
+ "encoding/json"
+ "errors"
+ "flag"
+ "fmt"
+ "io"
+ "log"
+ "net"
+ "os"
+ "time"
+)
+
+var (
+ debug = flag.Bool("debug", false, "enable debug output")
+ version = flag.Bool("version", false, "show version and exit")
+ projectName = "xR"
+ projectVersion = "?"
+)
+
+func now() string {
+ return time.Now().UTC().Format(time.RFC3339Nano)
+}
+
+func relayReadFrame(r io.Reader) bool {
+ var length uint32
+ if err := binary.Read(
+ r, binary.BigEndian, &length); errors.Is(err, io.EOF) {
+ return false
+ } else if err != nil {
+ log.Fatalln("Event receive failed: " + err.Error())
+ }
+ b := make([]byte, length)
+ if _, err := io.ReadFull(r, b); errors.Is(err, io.EOF) {
+ return false
+ } else if err != nil {
+ log.Fatalln("Event receive failed: " + err.Error())
+ }
+
+ m := struct {
+ When string `json:"when"`
+ Binary []byte `json:"raw"`
+ RelayEventMessage
+ }{
+ When: now(),
+ Binary: b,
+ }
+
+ if after, ok := m.RelayEventMessage.ConsumeFrom(b); !ok {
+ log.Println("Event deserialization failed")
+ } else if len(after) != 0 {
+ log.Println("Event deserialization failed: trailing data")
+ return true
+ }
+
+ j, err := json.Marshal(m)
+ if err != nil {
+ log.Fatalln("Event marshalling failed: " + err.Error())
+ }
+ fmt.Printf("%s\n", j)
+ return true
+}
+
+func run(addressConnect string) {
+ conn, err := net.Dial("tcp", addressConnect)
+ if err != nil {
+ log.Println("Connection failed: " + err.Error())
+ return
+ }
+ defer conn.Close()
+
+ // We can only support this one protocol version
+ // that proto.go has been generated for.
+ m := RelayCommandMessage{CommandSeq: 0, Data: RelayCommandData{
+ Variant: &RelayCommandDataHello{Version: RelayVersion},
+ }}
+
+ b, ok := m.AppendTo(make([]byte, 4))
+ if !ok {
+ log.Fatalln("Command serialization failed")
+ }
+ binary.BigEndian.PutUint32(b[:4], uint32(len(b)-4))
+ if _, err := conn.Write(b); err != nil {
+ log.Fatalln("Command send failed: " + err.Error())
+ }
+
+ // You can differentiate the direction by the presence
+ // of .data.command or .data.event.
+ if *debug {
+ j, err := json.Marshal(struct {
+ When string `json:"when"`
+ Binary []byte `json:"raw"`
+ RelayCommandMessage
+ }{
+ When: now(),
+ Binary: b,
+ RelayCommandMessage: m,
+ })
+ if err != nil {
+ log.Fatalln("Command marshalling failed: " + err.Error())
+ }
+ fmt.Printf("%s\n", j)
+ }
+
+ for relayReadFrame(conn) {
+ }
+}
+
+func main() {
+ flag.Usage = func() {
+ fmt.Fprintf(flag.CommandLine.Output(),
+ "Usage: %s [OPTION...] CONNECT\n\n", os.Args[0])
+ flag.PrintDefaults()
+ }
+ flag.Parse()
+ if *version {
+ fmt.Printf("%s %s (relay protocol version %d)\n",
+ projectName, projectVersion, RelayVersion)
+ return
+ }
+
+ if flag.NArg() != 1 {
+ flag.Usage()
+ os.Exit(1)
+ }
+
+ // TODO(p): This program should be able to run as a filter as well.
+ run(flag.Arg(0))
+}
diff --git a/xS/Dockerfile b/xS/Dockerfile
new file mode 100644
index 0000000..cb9ceb3
--- /dev/null
+++ b/xS/Dockerfile
@@ -0,0 +1,10 @@
+# TODO(p): Make it possible to store configuration.
+# docker/podman build --tag xs-irc --file xS/Dockerfile ..
+FROM alpine:latest
+LABEL org.opencontainers.image.url="https://git.janouch.name/p/xK"
+RUN apk add --no-cache build-base make go
+WORKDIR /xK/xS
+COPY .. /xK
+RUN make
+EXPOSE 6667
+ENTRYPOINT ["./xS"]
diff --git a/xT/CMakeLists.txt b/xT/CMakeLists.txt
new file mode 100644
index 0000000..562c15a
--- /dev/null
+++ b/xT/CMakeLists.txt
@@ -0,0 +1,179 @@
+# As per Qt 6.8 documentation, at least 3.16 is necessary
+cmake_minimum_required (VERSION 3.21.1)
+
+file (READ ../xK-version project_version)
+configure_file (../xK-version xK-version.tag COPYONLY)
+string (STRIP "${project_version}" project_version)
+
+# This is an entirely separate CMake project.
+project (xT VERSION "${project_version}"
+ DESCRIPTION "Qt frontend for xC" LANGUAGES CXX)
+
+set (CMAKE_CXX_STANDARD 17)
+set (CMAKE_CXX_STANDARD_REQUIRED ON)
+
+find_package (Qt6 REQUIRED COMPONENTS Widgets Network Multimedia
+ Quick QuickControls2)
+# XXX: The version requirement is probably for Qt Quick only.
+qt_standard_project_setup (REQUIRES 6.5)
+
+add_compile_options ("$<$<CXX_COMPILER_ID:MSVC>:/utf-8>")
+add_compile_options ("$<$<CXX_COMPILER_ID:GNU>:-Wall;-Wextra>")
+add_compile_options ("$<$<CXX_COMPILER_ID:Clang>:-Wall;-Wextra>")
+
+set (project_config "${PROJECT_BINARY_DIR}/config.h")
+configure_file ("${PROJECT_SOURCE_DIR}/config.h.in" "${project_config}")
+include_directories ("${PROJECT_SOURCE_DIR}" "${PROJECT_BINARY_DIR}")
+
+# Produce a beep sample
+find_program (sox_EXECUTABLE sox REQUIRED)
+set (beep "${PROJECT_BINARY_DIR}/beep.wav")
+add_custom_command (OUTPUT "${beep}"
+ COMMAND ${sox_EXECUTABLE} -b 16 -Dr 44100 -n "${beep}"
+ synth 0.1 0 25 triangle 800 vol 0.5 fade t 0 -0 0.005 pad 0 0.05
+ COMMENT "Generating a beep sample" VERBATIM)
+set_property (SOURCE "${beep}" APPEND PROPERTY QT_RESOURCE_ALIAS beep.wav)
+
+# Rasterize SVG icons
+set (root "${PROJECT_SOURCE_DIR}/..")
+set (CMAKE_MODULE_PATH "${root}/liberty/cmake")
+include (IconUtils)
+
+# It might generally be better to use QtSvg, though it is an extra dependency.
+# The icon_to_png macro is not intended to be used like this.
+foreach (icon xT xT-highlighted)
+ icon_to_png (${icon} "${PROJECT_SOURCE_DIR}/${icon}.svg"
+ 48 "${PROJECT_BINARY_DIR}/resources" icon_png)
+ set_property (SOURCE "${icon_png}"
+ APPEND PROPERTY QT_RESOURCE_ALIAS "${icon}.png")
+ list (APPEND icon_rsrc_list "${icon_png}")
+endforeach ()
+
+if (APPLE)
+ set (MACOSX_BUNDLE_ICON_FILE xT.icns)
+ icon_to_icns ("${PROJECT_SOURCE_DIR}/xT.svg"
+ "${MACOSX_BUNDLE_ICON_FILE}" icon_icns)
+else ()
+ # The largest size is mainly for an appropriately sized Windows icon
+ set (icon_base "${PROJECT_BINARY_DIR}/icons")
+ set (icon_png_list)
+ foreach (icon_size 16 32 48 256)
+ icon_to_png (xT "${PROJECT_SOURCE_DIR}/xT.svg"
+ ${icon_size} "${icon_base}" icon_png)
+ list (APPEND icon_png_list "${icon_png}")
+ endforeach ()
+ add_custom_target (icons ALL DEPENDS ${icon_png_list})
+ if (WIN32)
+ list (REMOVE_ITEM icon_png_list "${icon_png}")
+ set (icon_ico "${PROJECT_BINARY_DIR}/xT.ico")
+ icon_for_win32 ("${icon_ico}" "${icon_png_list}" "${icon_png}")
+
+ set (resource_file "${PROJECT_BINARY_DIR}/xT.rc")
+ list (APPEND project_sources "${resource_file}")
+ add_custom_command (OUTPUT "${resource_file}"
+ COMMAND ${CMAKE_COMMAND} -E echo "1 ICON \"xT.ico\""
+ > ${resource_file} VERBATIM)
+ set_property (SOURCE "${resource_file}"
+ APPEND PROPERTY OBJECT_DEPENDS ${icon_ico})
+ endif ()
+endif ()
+
+# Build the main executable and link it
+find_program (awk_EXECUTABLE awk REQUIRED)
+add_custom_command (OUTPUT xC-proto.cpp
+ COMMAND ${CMAKE_COMMAND} -E env LC_ALL=C ${awk_EXECUTABLE}
+ -f ${root}/liberty/tools/lxdrgen.awk
+ -f ${root}/liberty/tools/lxdrgen-cpp.awk
+ -v PrefixCamel=Relay
+ ${root}/xC.lxdr > xC-proto.cpp
+ DEPENDS
+ ${root}/liberty/tools/lxdrgen.awk
+ ${root}/liberty/tools/lxdrgen-cpp.awk
+ ${root}/xC.lxdr
+ COMMENT "Generating xC relay protocol code" VERBATIM)
+add_custom_target (xC-proto DEPENDS ${PROJECT_BINARY_DIR}/xC-proto.cpp)
+
+list (APPEND project_sources "${root}/liberty/tools/lxdrgen-cpp-qt.cpp")
+qt_add_executable (xT
+ xT.cpp ${project_config} ${project_sources} "${icon_icns}")
+add_dependencies (xT xC-proto)
+qt_add_resources (xT "rsrc" PREFIX / FILES "${beep}" ${icon_rsrc_list})
+target_link_libraries (xT PRIVATE Qt6::Widgets Qt6::Network Qt6::Multimedia)
+set_target_properties (xT PROPERTIES WIN32_EXECUTABLE ON MACOSX_BUNDLE ON
+ MACOSX_BUNDLE_GUI_IDENTIFIER name.janouch.xT)
+
+# https://stackoverflow.com/questions/79079161 and resolved in Qt Creator 16.
+set (QT_QML_GENERATE_QMLLS_INI ON)
+
+# TODO(p): Perhaps do it in one-or-the-other way,
+# as Qt Quick sucks on the desktop, and Qt Widgets is unusable on mobile.
+qt_add_executable (xTq
+ xTq.cpp ${project_config} ${project_sources} "${icon_icns}")
+set_property (SOURCE xTq.qml APPEND PROPERTY QT_QML_SOURCE_TYPENAME Main)
+qt_add_qml_module (xTq URI xTquick VERSION 1.0 QML_FILES xTq.qml)
+add_dependencies (xTq xC-proto)
+qt_add_resources (xTq "rsrc" PREFIX / FILES "${beep}" ${icon_rsrc_list})
+target_link_libraries (xTq PRIVATE
+ Qt6::Quick Qt6::QuickControls2 Qt6::Network Qt6::Multimedia)
+set_target_properties (xTq PROPERTIES WIN32_EXECUTABLE ON MACOSX_BUNDLE ON
+ MACOSX_BUNDLE_GUI_IDENTIFIER name.janouch.xTq)
+
+# The files to be installed
+include (GNUInstallDirs)
+
+if (ANDROID)
+ install (TARGETS xTq DESTINATION .)
+elseif (APPLE OR WIN32)
+ install (TARGETS xT
+ BUNDLE DESTINATION .
+ RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR})
+
+ # XXX: QTBUG-127075, which can be circumvented by manually running
+ # macdeployqt on xT.app before the install.
+ qt_generate_deploy_app_script (TARGET xT OUTPUT_SCRIPT deploy_xT)
+ install (SCRIPT "${deploy_xT}")
+else ()
+ install (TARGETS xT DESTINATION ${CMAKE_INSTALL_BINDIR})
+ install (FILES ../LICENSE DESTINATION ${CMAKE_INSTALL_DOCDIR})
+
+ install (FILES xT.svg
+ DESTINATION ${CMAKE_INSTALL_DATADIR}/icons/hicolor/scalable/apps)
+ install (DIRECTORY ${icon_base}
+ DESTINATION ${CMAKE_INSTALL_DATADIR})
+ install (FILES xT.desktop
+ DESTINATION ${CMAKE_INSTALL_DATADIR}/applications)
+endif ()
+
+# Within MSYS2, windeployqt doesn't copy the compiler runtime,
+# which is always linked dynamically by the Qt binaries.
+# TODO(p): Consider whether or not to use MSYS2 to cross-compile, and how.
+if (WIN32)
+ install (CODE [=[
+ set (bindir "${CMAKE_INSTALL_PREFIX}/bin")
+ execute_process (COMMAND cygpath -m /
+ OUTPUT_VARIABLE cygroot OUTPUT_STRIP_TRAILING_WHITESPACE)
+ if (cygroot)
+ execute_process (COMMAND ldd "${bindir}/xT.exe"
+ OUTPUT_VARIABLE ldd_output OUTPUT_STRIP_TRAILING_WHITESPACE)
+ string (REGEX MATCHALL " /mingw64/bin/[^ ]+ " libs "${ldd_output}")
+ foreach (lib ${libs})
+ string (STRIP "${lib}" lib)
+ file (COPY "${cygroot}${lib}" DESTINATION "${bindir}")
+ endforeach ()
+ endif ()
+ ]=])
+endif ()
+
+# CPack
+set (CPACK_PACKAGE_VENDOR "Premysl Eric Janouch")
+set (CPACK_PACKAGE_CONTACT "Přemysl Eric Janouch <p@janouch.name>")
+set (CPACK_RESOURCE_FILE_LICENSE "${PROJECT_SOURCE_DIR}/../LICENSE")
+set (CPACK_GENERATOR "TGZ;ZIP")
+set (CPACK_PACKAGE_FILE_NAME
+ "${PROJECT_NAME}-${PROJECT_VERSION}-${CMAKE_SYSTEM_NAME}-${CMAKE_SYSTEM_PROCESSOR}")
+set (CPACK_PACKAGE_INSTALL_DIRECTORY "${PROJECT_NAME} ${PROJECT_VERSION}")
+set (CPACK_SOURCE_GENERATOR "TGZ;ZIP")
+set (CPACK_SOURCE_IGNORE_FILES "/build;/CMakeLists.txt.user")
+set (CPACK_SOURCE_PACKAGE_FILE_NAME "${PROJECT_NAME}-${PROJECT_VERSION}")
+
+include (CPack)
diff --git a/xT/config.h.in b/xT/config.h.in
new file mode 100644
index 0000000..d31abdd
--- /dev/null
+++ b/xT/config.h.in
@@ -0,0 +1,7 @@
+#ifndef CONFIG_H
+#define CONFIG_H
+
+#define PROJECT_NAME "${PROJECT_NAME}"
+#define PROJECT_VERSION "${project_version}"
+
+#endif // ! CONFIG_H
diff --git a/xT/xT-highlighted.svg b/xT/xT-highlighted.svg
new file mode 100644
index 0000000..e624b4b
--- /dev/null
+++ b/xT/xT-highlighted.svg
@@ -0,0 +1,29 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg version="1.1" width="48" height="48" viewBox="0 0 48 48"
+ xmlns="http://www.w3.org/2000/svg">
+
+ <defs>
+ <radialGradient id="green-x">
+ <stop stop-color="hsl(66, 100%, 80%)" offset="0" />
+ <stop stop-color="hsl(66, 100%, 50%)" offset="1" />
+ </radialGradient>
+ <radialGradient id="orange">
+ <stop stop-color="hsl(36, 100%, 60%)" offset="0" />
+ <stop stop-color="hsl(23, 100%, 60%)" offset="1" />
+ </radialGradient>
+ <filter id="shadow" x="-50%" y="-50%" width="200%" height="200%">
+ <feDropShadow dx="0" dy="0" stdDeviation="0.05"
+ flood-color="rgba(0, 0, 0, .5)" />
+ </filter>
+ </defs>
+
+ <!-- XXX: librsvg screws up shadows on rotated objects. -->
+ <g filter="url(#shadow)" transform="translate(24 3) scale(16)">
+ <path fill="url(#orange)" stroke="hsl(36, 100%, 20%)" stroke-width="0.1"
+ d="M-.8 0 H.8 V.5 H.25 V2.625 H-.25 V.5 H-.8 Z" />
+ </g>
+ <g filter="url(#shadow)" transform="translate(24 28) rotate(-45) scale(16)">
+ <path fill="url(#green-x)" stroke="hsl(66, 100%, 20%)" stroke-width="0.1"
+ d="M-.25 -1 H.25 V-.25 H1 V.25 H.25 V1 H-.25 V.25 H-1 V-.25 H-.25 Z" />
+ </g>
+</svg>
diff --git a/xT/xT.cpp b/xT/xT.cpp
new file mode 100644
index 0000000..b708b95
--- /dev/null
+++ b/xT/xT.cpp
@@ -0,0 +1,1734 @@
+/*
+ * xT.cpp: Qt Widgets frontend for xC
+ *
+ * Copyright (c) 2024, Přemysl Eric Janouch <p@janouch.name>
+ *
+ * Permission to use, copy, modify, and/or distribute this software for any
+ * purpose with or without fee is hereby granted.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY
+ * SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION
+ * OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN
+ * CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ *
+ */
+
+#include "xC-proto.cpp"
+#include "config.h"
+
+#include <cstdint>
+#include <functional>
+#include <map>
+#include <string>
+
+#include <QtEndian>
+#include <QtDebug>
+
+#include <QDateTime>
+#include <QRegularExpression>
+
+#include <QApplication>
+#include <QFontDatabase>
+#include <QFormLayout>
+#include <QHBoxLayout>
+#include <QKeyEvent>
+#include <QLabel>
+#include <QLineEdit>
+#include <QListWidget>
+#include <QMainWindow>
+#include <QMessageBox>
+#include <QPushButton>
+#include <QScrollBar>
+#include <QShortcut>
+#include <QSplitter>
+#include <QStackedWidget>
+#include <QTextBrowser>
+#include <QTextBlock>
+#include <QTextEdit>
+#include <QTimer>
+#include <QToolButton>
+#include <QVBoxLayout>
+#include <QWindow>
+
+#include <QSoundEffect>
+#include <QTcpSocket>
+
+struct Server {
+ Relay::ServerState state = {};
+ QString user;
+ QString user_modes;
+};
+
+struct BufferLineItem {
+ QTextCharFormat format = {};
+ QString text;
+};
+
+struct BufferLine {
+ /// Leaked from another buffer, but temporarily staying in another one.
+ bool leaked = {};
+
+ bool is_unimportant = {};
+ bool is_highlight = {};
+ Relay::Rendition rendition = {};
+ uint64_t when = {};
+ std::vector<BufferLineItem> items;
+};
+
+struct Buffer {
+ QString buffer_name;
+ bool hide_unimportant = {};
+ Relay::BufferKind kind = {};
+ QString server_name;
+ std::vector<BufferLine> lines;
+
+ // Channel:
+
+ std::vector<BufferLineItem> topic;
+ QString modes;
+
+ // Stats:
+
+ uint32_t new_messages = {};
+ uint32_t new_unimportant_messages = {};
+ bool highlighted = {};
+
+ // Input:
+
+ // The input is stored as rich text.
+ QString input;
+ int input_start = {};
+ int input_end = {};
+ std::vector<QString> history;
+ size_t history_at = {};
+};
+
+using Callback = std::function<
+ void(std::wstring error, const Relay::ResponseData *response)>;
+
+struct {
+ QMainWindow *wMain; ///< Main program window
+ QLabel *wTopic; ///< Channel topic
+ QListWidget *wBufferList; ///< Buffer list
+ QStackedWidget *wStack; ///< Buffer backlog/log stack
+ QTextBrowser *wBuffer; ///< Buffer backlog
+ QTextBrowser *wLog; ///< Buffer log
+ QLabel *wPrompt; ///< User name, etc.
+ QToolButton *wButtonB; ///< Toggle bold formatting
+ QToolButton *wButtonI; ///< Toggle italic formatting
+ QToolButton *wButtonU; ///< Toggle underlined formatting
+ QLabel *wStatus; ///< Buffer name, etc.
+ QToolButton *wButtonLog; ///< Buffer log toggle button
+ QToolButton *wButtonDown; ///< Scroll indicator
+ QTextEdit *wInput; ///< User input
+
+ QTimer *date_change_timer; ///< Timer for day changes
+
+ QLineEdit *wConnectHost; ///< Connection dialog: host
+ QLineEdit *wConnectPort; ///< Connection dialog: port
+ QDialog *wConnectDialog; ///< Connection details dialog
+
+ // Networking:
+
+ QString host; ///< Host as given by user
+ QString port; ///< Post/service as given by user
+
+ QTcpSocket *socket; ///< Buffered relay socket
+
+ // Relay protocol:
+
+ uint32_t command_seq; ///< Outgoing message counter
+
+ std::map<uint32_t, Callback> command_callbacks;
+
+ std::vector<Buffer> buffers; ///< List of all buffers
+ QString buffer_current; ///< Current buffer name or ""
+ QString buffer_last; ///< Previous buffer name or ""
+
+ std::map<QString, Server> servers;
+} g;
+
+static void
+show_error_message(const QString &message)
+{
+ QMessageBox::critical(g.wMain, "Error", message, QMessageBox::Ok);
+}
+
+static void
+beep()
+{
+ // We don't want to reuse the same instance.
+ auto *se = new QSoundEffect(g.wMain);
+ QObject::connect(se, &QSoundEffect::playingChanged, [=] {
+ if (!se->isPlaying())
+ se->deleteLater();
+ });
+ QObject::connect(se, &QSoundEffect::statusChanged, [=] {
+ if (se->status() == QSoundEffect::Error)
+ se->deleteLater();
+ });
+
+ se->setSource(QUrl("qrc:/beep.wav"));
+ se->setLoopCount(1);
+ se->setVolume(0.5);
+ se->play();
+}
+
+// --- Networking --------------------------------------------------------------
+
+static void
+on_relay_generic_response(
+ std::wstring error, const Relay::ResponseData *response)
+{
+ if (!response)
+ show_error_message(QString::fromStdWString(error));
+}
+
+static void
+relay_send(Relay::CommandData *data, Callback callback = {})
+{
+ Relay::CommandMessage m = {};
+ m.command_seq = ++g.command_seq;
+ m.data.reset(data);
+ LibertyXDR::Writer w;
+ m.serialize(w);
+
+ if (callback)
+ g.command_callbacks[m.command_seq] = std::move(callback);
+ else
+ g.command_callbacks[m.command_seq] = on_relay_generic_response;
+
+ auto len = qToBigEndian<uint32_t>(w.data.size());
+ auto prefix = reinterpret_cast<const char *>(&len);
+ auto mdata = reinterpret_cast<const char *>(w.data.data());
+ if (g.socket->write(prefix, sizeof len) < 0 ||
+ g.socket->write(mdata, w.data.size()) < 0) {
+ g.socket->abort();
+ }
+}
+
+// --- Buffers -----------------------------------------------------------------
+
+static Buffer *
+buffer_by_name(const QString &name)
+{
+ for (auto &b : g.buffers)
+ if (b.buffer_name == name)
+ return &b;
+ return nullptr;
+}
+
+static Buffer *
+buffer_by_name(const std::wstring &name)
+{
+ // The C++ LibertyXDR backend unfortunately targets Win32.
+ return buffer_by_name(QString::fromStdWString(name));
+}
+
+static bool
+buffer_at_bottom()
+{
+ auto sb = g.wBuffer->verticalScrollBar();
+ return sb->value() == sb->maximum();
+}
+
+static void
+buffer_scroll_to_bottom()
+{
+ auto sb = g.wBuffer->verticalScrollBar();
+ sb->setValue(sb->maximum());
+}
+
+// --- UI state refresh --------------------------------------------------------
+
+static void
+refresh_icon()
+{
+ // This blocks Linux themes, but oh well.
+ QIcon icon(":/xT.png");
+ for (const auto &b : g.buffers)
+ if (b.highlighted)
+ icon = QIcon(":/xT-highlighted.png");
+
+ g.wMain->setWindowIcon(icon);
+}
+
+static void
+textedit_replacesel(
+ QTextEdit *e, const QTextCharFormat &cf, const QString &text)
+{
+ auto cursor = e->textCursor();
+ if (cf.fontFixedPitch()) {
+ auto fixed = QFontDatabase::systemFont(QFontDatabase::FixedFont);
+ auto adjusted = cf;
+ // For some reason, setting the families to empty also works.
+ adjusted.setFontFamilies(fixed.families());
+ cursor.setCharFormat(adjusted);
+ } else {
+ cursor.setCharFormat(cf);
+ }
+ cursor.insertText(text);
+}
+
+static void
+refresh_topic(const std::vector<BufferLineItem> &topic)
+{
+ QTextDocument doc;
+ QTextCursor cursor(&doc);
+ for (const auto &it : topic) {
+ cursor.setCharFormat(it.format);
+ cursor.insertText(it.text);
+ }
+ g.wTopic->setText(doc.toHtml());
+}
+
+static void
+refresh_buffer_list_item(QListWidgetItem *item, const Buffer &b)
+{
+ auto text = b.buffer_name;
+ QFont font;
+ QBrush color;
+ if (b.buffer_name != g.buffer_current && b.new_messages) {
+ text += " (" + QString::number(b.new_messages) + ")";
+ font.setBold(true);
+ }
+ if (b.highlighted)
+ color = QColor(0xff, 0x5f, 0x00);
+
+ item->setForeground(color);
+ item->setText(text);
+ item->setFont(font);
+}
+
+static void
+refresh_buffer_list()
+{
+ for (size_t i = 0; i < g.buffers.size(); i++)
+ refresh_buffer_list_item(g.wBufferList->item(i), g.buffers.at(i));
+}
+
+static QString
+server_state_to_string(Relay::ServerState state)
+{
+ switch (state) {
+ case Relay::ServerState::DISCONNECTED: return "disconnected";
+ case Relay::ServerState::CONNECTING: return "connecting";
+ case Relay::ServerState::CONNECTED: return "connected";
+ case Relay::ServerState::REGISTERED: return "registered";
+ case Relay::ServerState::DISCONNECTING: return "disconnecting";
+ }
+ return {};
+}
+
+static void
+refresh_prompt()
+{
+ QString prompt;
+ auto b = buffer_by_name(g.buffer_current);
+ if (!b) {
+ prompt = "Synchronizing...";
+ } else if (auto server = g.servers.find(b->server_name);
+ server != g.servers.end()) {
+ prompt = server->second.user;
+ if (!server->second.user_modes.isEmpty())
+ prompt += "(" + server->second.user_modes + ")";
+ if (prompt.isEmpty())
+ prompt = "(" + server_state_to_string(server->second.state) + ")";
+ }
+ g.wPrompt->setText(prompt);
+}
+
+static void
+refresh_status()
+{
+ g.wButtonDown->setEnabled(!buffer_at_bottom());
+
+ QString status = g.buffer_current;
+ if (auto b = buffer_by_name(g.buffer_current)) {
+ if (!b->modes.isEmpty())
+ status += "(+" + b->modes + ")";
+ if (b->hide_unimportant)
+ status += "<H>";
+ }
+
+ // Buffer scrolling would cause a ton of flickering redraws.
+ if (g.wStatus->text() != status)
+ g.wStatus->setText(status);
+}
+
+static void
+recheck_highlighted()
+{
+ // Corresponds to the logic toggling the bool on.
+ auto b = buffer_by_name(g.buffer_current);
+ if (b && b->highlighted && buffer_at_bottom() &&
+ !g.wMain->isMinimized() && !g.wLog->isVisible()) {
+ b->highlighted = false;
+ refresh_icon();
+ refresh_buffer_list();
+ }
+}
+
+// --- Buffer actions ----------------------------------------------------------
+
+static void
+buffer_activate(const QString &name)
+{
+ auto activate = new Relay::CommandData_BufferActivate();
+ activate->buffer_name = name.toStdWString();
+ relay_send(activate);
+}
+
+static void
+buffer_toggle_unimportant(const QString &name)
+{
+ auto toggle = new Relay::CommandData_BufferToggleUnimportant();
+ toggle->buffer_name = name.toStdWString();
+ relay_send(toggle);
+}
+
+// FIXME: This works on the wrong level, we should take a vector and output
+// a filtered vector--we must disregard individual items during URL matching.
+static void
+convert_links(const QTextCharFormat &format, const QString &text,
+ std::vector<BufferLineItem> &result)
+{
+ static QRegularExpression link_re(
+ R"(https?://)"
+ R"((?:[^\[\](){}<>"'\s]|\([^\[\](){}<>"'\s]*\))+)"
+ R"((?:[^\[\](){}<>"'\s,.:]|\([^\[\](){}<>"'\s]*\)))");
+
+ qsizetype end = 0;
+ for (const QRegularExpressionMatch &m : link_re.globalMatch(text)) {
+ if (end < m.capturedStart()) {
+ result.emplace_back(BufferLineItem{
+ format, text.sliced(end, m.capturedStart() - end)});
+ }
+
+ BufferLineItem item{format, m.captured()};
+ item.format.setAnchor(true);
+ item.format.setAnchorHref(m.captured());
+ result.emplace_back(std::move(item));
+
+ end = m.capturedEnd();
+ }
+ if (!end)
+ result.emplace_back(BufferLineItem{format, text});
+ else if (end < text.length())
+ result.emplace_back(BufferLineItem{format, text.sliced(end)});
+}
+
+static void
+buffer_toggle_log(
+ const std::wstring &error, const Relay::ResponseData_BufferLog *response)
+{
+ if (!response) {
+ show_error_message(QString::fromStdWString(error));
+ return;
+ }
+
+ std::wstring log;
+ if (!LibertyXDR::utf8_to_wstring(
+ response->log.data(), response->log.size(), log)) {
+ show_error_message("Invalid encoding.");
+ return;
+ }
+
+ std::vector<BufferLineItem> linkified;
+ convert_links({}, QString::fromStdWString(log), linkified);
+
+ g.wButtonLog->setChecked(true);
+ g.wLog->setText({});
+ for (const auto &it : linkified)
+ textedit_replacesel(g.wLog, it.format, it.text);
+ g.wStack->setCurrentWidget(g.wLog);
+
+ // This triggers a relayout of some kind.
+ auto cursor = g.wLog->textCursor();
+ cursor.movePosition(QTextCursor::End);
+ g.wLog->setTextCursor(cursor);
+
+ auto sb = g.wLog->verticalScrollBar();
+ sb->setValue(sb->maximum());
+}
+
+static void
+buffer_toggle_log()
+{
+ if (g.wLog->isVisible()) {
+ g.wStack->setCurrentWidget(g.wBuffer);
+ g.wLog->setText("");
+ g.wButtonLog->setChecked(false);
+
+ recheck_highlighted();
+ return;
+ }
+
+ auto log = new Relay::CommandData_BufferLog();
+ log->buffer_name = g.buffer_current.toStdWString();
+ relay_send(log, [name = g.buffer_current](auto error, auto response) {
+ if (g.buffer_current != name)
+ return;
+ buffer_toggle_log(error,
+ dynamic_cast<const Relay::ResponseData_BufferLog *>(response));
+ });
+}
+
+// --- QTextEdit formatting ----------------------------------------------------
+
+static QString
+rich_text_to_irc(QTextEdit *textEdit)
+{
+ QString irc;
+ for (auto block = textEdit->document()->begin();
+ block.isValid(); block = block.next()) {
+ for (auto it = block.begin(); it != block.end(); ++it) {
+ auto fragment = it.fragment();
+ if (!fragment.isValid())
+ continue;
+
+ // TODO(p): Colours.
+ QString toggles;
+ auto format = fragment.charFormat();
+ if (format.fontWeight() >= QFont::Bold)
+ toggles += "\x02";
+ if (format.fontFixedPitch())
+ toggles += "\x11";
+ if (format.fontItalic())
+ toggles += "\x1d";
+ if (format.fontStrikeOut())
+ toggles += "\x1e";
+ if (format.fontUnderline())
+ toggles += "\x1f";
+ irc += toggles + fragment.text() + toggles;
+ }
+ if (block.next().isValid())
+ irc += "\n";
+ }
+ return irc;
+}
+
+static QString
+irc_to_rich_text(const QString &irc)
+{
+ QTextDocument doc;
+ QTextCursor cursor(&doc);
+ QTextCharFormat cf;
+ bool bold = false, monospace = false, italic = false, crossed = false,
+ underline = false;
+
+ QString current;
+ auto apply = [&]() {
+ if (!current.isEmpty()) {
+ cursor.insertText(current, cf);
+ current.clear();
+ }
+ };
+
+ for (int i = 0; i < irc.length(); ++i) {
+ switch (irc[i].unicode()) {
+ case '\x02':
+ apply();
+ bold = !bold;
+ cf.setFontWeight(bold ? QFont::Bold : QFont::Normal);
+ break;
+ case '\x03':
+ // TODO(p): Decode colours, see xC.
+ break;
+ case '\x11':
+ apply();
+ cf.setFontFixedPitch((monospace = !monospace));
+ break;
+ case '\x1d':
+ apply();
+ cf.setFontItalic((italic = !italic));
+ break;
+ case '\x1e':
+ apply();
+ cf.setFontItalic((crossed = !crossed));
+ break;
+ case '\x1f':
+ apply();
+ cf.setFontUnderline((underline = !underline));
+ break;
+ case '\x0f':
+ apply();
+ bold = monospace = italic = crossed = underline = false;
+ cf = QTextCharFormat();
+ break;
+ default:
+ current += irc[i];
+ }
+ }
+ apply();
+ return doc.toHtml();
+}
+
+// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+
+static QBrush
+convert_color(int16_t color)
+{
+ static const uint16_t base16[] = {
+ 0x000, 0x800, 0x080, 0x880, 0x008, 0x808, 0x088, 0xccc,
+ 0x888, 0xf00, 0x0f0, 0xff0, 0x00f, 0xf0f, 0x0ff, 0xfff,
+ };
+ if (color < 16) {
+ uint8_t r = 0xf & (base16[color] >> 8);
+ uint8_t g = 0xf & (base16[color] >> 4);
+ uint8_t b = 0xf & (base16[color]);
+ return QColor(r * 0x11, g * 0x11, b * 0x11);
+ }
+ if (color >= 216) {
+ uint8_t g = 8 + (color - 216) * 10;
+ return QColor(g, g, g);
+ }
+
+ uint8_t i = color - 16, r = i / 36 >> 0, g = (i / 6 >> 0) % 6, b = i % 6;
+ return QColor(
+ !r ? 0 : 55 + 40 * r,
+ !g ? 0 : 55 + 40 * g,
+ !b ? 0 : 55 + 40 * b);
+}
+
+static void
+convert_item_formatting(
+ Relay::ItemData *item, QTextCharFormat &cf, bool &inverse)
+{
+ if (dynamic_cast<Relay::ItemData_Reset *>(item)) {
+ cf = QTextCharFormat();
+ inverse = false;
+ } else if (dynamic_cast<Relay::ItemData_FlipBold *>(item)) {
+ if (cf.fontWeight() <= QFont::Normal)
+ cf.setFontWeight(QFont::Bold);
+ else
+ cf.setFontWeight(QFont::Normal);
+ } else if (dynamic_cast<Relay::ItemData_FlipItalic *>(item)) {
+ cf.setFontItalic(!cf.fontItalic());
+ } else if (dynamic_cast<Relay::ItemData_FlipUnderline *>(item)) {
+ cf.setFontUnderline(!cf.fontUnderline());
+ } else if (dynamic_cast<Relay::ItemData_FlipCrossedOut *>(item)) {
+ cf.setFontStrikeOut(!cf.fontStrikeOut());
+ } else if (dynamic_cast<Relay::ItemData_FlipInverse *>(item)) {
+ inverse = !inverse;
+ } else if (dynamic_cast<Relay::ItemData_FlipMonospace *>(item)) {
+ cf.setFontFixedPitch(!cf.fontFixedPitch());
+ } else if (auto data = dynamic_cast<Relay::ItemData_FgColor *>(item)) {
+ if (data->color < 0) {
+ cf.clearForeground();
+ } else {
+ cf.setForeground(convert_color(data->color));
+ }
+ } else if (auto data = dynamic_cast<Relay::ItemData_BgColor *>(item)) {
+ if (data->color < 0) {
+ cf.clearBackground();
+ } else {
+ cf.setBackground(convert_color(data->color));
+ }
+ }
+}
+
+static std::vector<BufferLineItem>
+convert_items(const std::vector<std::unique_ptr<Relay::ItemData>> &items)
+{
+ QTextCharFormat cf;
+ std::vector<BufferLineItem> result;
+ bool inverse = false;
+ for (const auto &it : items) {
+ auto text = dynamic_cast<Relay::ItemData_Text *>(it.get());
+ if (!text) {
+ convert_item_formatting(it.get(), cf, inverse);
+ continue;
+ }
+
+ auto item_format = cf;
+ auto item_text = QString::fromStdWString(text->text);
+ if (inverse) {
+ auto fg = item_format.foreground();
+ auto bg = item_format.background();
+ item_format.setBackground(fg);
+ item_format.setForeground(bg);
+ }
+ convert_links(item_format, item_text, result);
+ }
+ return result;
+}
+
+// --- Buffer output -----------------------------------------------------------
+
+static BufferLine
+convert_buffer_line(Relay::EventData_BufferLine &line)
+{
+ BufferLine self = {};
+ self.items = convert_items(line.items);
+ self.is_unimportant = line.is_unimportant;
+ self.is_highlight = line.is_highlight;
+ self.rendition = line.rendition;
+ self.when = line.when;
+ return self;
+}
+
+static void
+buffer_print_date_change(
+ bool &sameline, const QDateTime &last, const QDateTime &current)
+{
+ if (last.date() == current.date())
+ return;
+
+ auto timestamp = current.toString(&"\n"[sameline] +
+ QLocale::system().dateFormat(QLocale::ShortFormat));
+ sameline = false;
+
+ QTextCharFormat cf;
+ cf.setFontWeight(QFont::Bold);
+ textedit_replacesel(g.wBuffer, cf, timestamp);
+}
+
+static bool
+buffer_reset_selection()
+{
+ auto sb = g.wBuffer->verticalScrollBar();
+ auto value = sb->value();
+ g.wBuffer->moveCursor(QTextCursor::End);
+ sb->setValue(value);
+ return g.wBuffer->textCursor().atBlockStart();
+}
+
+static void
+buffer_print_and_watch_trailing_date_changes()
+{
+ auto current = QDateTime::currentDateTime();
+ auto b = buffer_by_name(g.buffer_current);
+ if (b && !b->lines.empty()) {
+ auto last = QDateTime::fromMSecsSinceEpoch(b->lines.back().when);
+ bool sameline = buffer_reset_selection();
+ buffer_print_date_change(sameline, last, current);
+ }
+
+ QDateTime midnight(current.date().addDays(1), {});
+ if (midnight < current)
+ return;
+
+ // Note that after printing the first trailing update,
+ // follow-up updates may be duplicated if timer events arrive too early.
+ g.date_change_timer->start(current.msecsTo(midnight) + 1);
+}
+
+static void
+buffer_print_line(std::vector<BufferLine>::const_iterator begin,
+ std::vector<BufferLine>::const_iterator line)
+{
+ auto current = QDateTime::fromMSecsSinceEpoch(line->when);
+ auto last = line == begin ? QDateTime::currentDateTime()
+ : QDateTime::fromMSecsSinceEpoch((line - 1)->when);
+
+ bool sameline = buffer_reset_selection();
+ buffer_print_date_change(sameline, last, current);
+
+ auto timestamp = current.toString(&"\nHH:mm:ss"[sameline]);
+ sameline = false;
+
+ QTextCharFormat cf;
+ cf.setForeground(QColor(0xbb, 0xbb, 0xbb));
+ cf.setBackground(QColor(0xf8, 0xf8, 0xf8));
+ textedit_replacesel(g.wBuffer, cf, timestamp);
+ cf = QTextCharFormat();
+ textedit_replacesel(g.wBuffer, cf, " ");
+
+ // Tabstops won't quite help us here, since we need it centred.
+ QString prefix;
+ QTextCharFormat pcf;
+ pcf.setFontFixedPitch(true);
+ pcf.setFontWeight(QFont::Bold);
+ switch (line->rendition) {
+ break; case Relay::Rendition::BARE:
+ break; case Relay::Rendition::INDENT:
+ prefix = " ";
+ break; case Relay::Rendition::STATUS:
+ prefix = " - ";
+ break; case Relay::Rendition::ERROR:
+ prefix = "=!= ";
+ pcf.setForeground(QColor(0xff, 0, 0));
+ break; case Relay::Rendition::JOIN:
+ prefix = "——> ";
+ pcf.setForeground(QColor(0, 0x88, 0));
+ break; case Relay::Rendition::PART:
+ prefix = "<—— ";
+ pcf.setForeground(QColor(0x88, 0, 0));
+ break; case Relay::Rendition::ACTION:
+ prefix = " * ";
+ pcf.setForeground(QColor(0x88, 0, 0));
+ }
+
+ if (line->leaked) {
+ auto color = g.wBuffer->palette().color(
+ QPalette::Disabled, QPalette::Text);
+ pcf.setForeground(color);
+ if (!prefix.isEmpty()) {
+ textedit_replacesel(g.wBuffer, pcf, prefix);
+ }
+
+ for (auto it : line->items) {
+ it.format.setForeground(color);
+ it.format.clearBackground();
+ textedit_replacesel(g.wBuffer, it.format, it.text);
+ }
+ } else {
+ if (!prefix.isEmpty())
+ textedit_replacesel(g.wBuffer, pcf, prefix);
+ for (const auto &it : line->items)
+ textedit_replacesel(g.wBuffer, it.format, it.text);
+ }
+}
+
+static void
+buffer_print_separator()
+{
+ buffer_reset_selection();
+
+ QTextFrameFormat ff;
+ ff.setBackground(QColor(0xff, 0x5f, 0x00));
+ ff.setHeight(1);
+ // FIXME: When the current frame was empty, this seems to add a newline.
+ g.wBuffer->textCursor().insertFrame(ff);
+}
+
+static void
+refresh_buffer(const Buffer &b)
+{
+ g.wBuffer->clear();
+
+ size_t i = 0, mark_before = b.lines.size() -
+ b.new_messages - b.new_unimportant_messages;
+ for (auto line = b.lines.begin(); line != b.lines.end(); ++line) {
+ if (i == mark_before)
+ buffer_print_separator();
+ if (!line->is_unimportant || !b.hide_unimportant)
+ buffer_print_line(b.lines.begin(), line);
+
+ i++;
+ }
+
+ buffer_print_and_watch_trailing_date_changes();
+ buffer_scroll_to_bottom();
+ // TODO(p): recheck_highlighted() here, or do we handle enough signals?
+}
+
+// --- Event processing --------------------------------------------------------
+
+static void
+relay_process_buffer_line(Buffer &b, Relay::EventData_BufferLine &m)
+{
+ // Initial sync: skip all other processing, let highlights be.
+ auto bc = buffer_by_name(g.buffer_current);
+ if (!bc) {
+ b.lines.push_back(convert_buffer_line(m));
+ return;
+ }
+
+ // Retained mode is complicated.
+ bool display = (!m.is_unimportant || !bc->hide_unimportant) &&
+ (b.buffer_name == g.buffer_current || m.leak_to_active);
+ bool to_bottom = display && buffer_at_bottom();
+ bool visible = display &&
+ to_bottom &&
+ !g.wMain->isMinimized() &&
+ !g.wLog->isVisible();
+ bool separate = display &&
+ !visible && !bc->new_messages && !bc->new_unimportant_messages;
+
+ auto line = b.lines.insert(b.lines.end(), convert_buffer_line(m));
+ if (!(visible || m.leak_to_active) ||
+ b.new_messages || b.new_unimportant_messages) {
+ if (line->is_unimportant || m.leak_to_active)
+ b.new_unimportant_messages++;
+ else
+ b.new_messages++;
+ }
+
+ if (m.leak_to_active) {
+ auto line = bc->lines.insert(bc->lines.end(), convert_buffer_line(m));
+ line->leaked = true;
+ if (!visible || bc->new_messages || bc->new_unimportant_messages) {
+ if (line->is_unimportant)
+ bc->new_unimportant_messages++;
+ else
+ bc->new_messages++;
+ }
+ }
+ if (separate)
+ buffer_print_separator();
+ if (display)
+ buffer_print_line(bc->lines.begin(), bc->lines.end() - 1);
+ if (to_bottom)
+ buffer_scroll_to_bottom();
+
+ if (line->is_highlight || (!visible && !line->is_unimportant &&
+ b.kind == Relay::BufferKind::PRIVATE_MESSAGE)) {
+ beep();
+
+ if (!visible) {
+ b.highlighted = true;
+ refresh_icon();
+ }
+ }
+
+ refresh_buffer_list();
+}
+
+static void
+relay_process_callbacks(uint32_t command_seq,
+ const std::wstring& error, const Relay::ResponseData *response)
+{
+ auto &callbacks = g.command_callbacks;
+ auto handler = callbacks.find(command_seq);
+ if (handler == callbacks.end()) {
+ // TODO(p): Warn about an unawaited response.
+ } else {
+ if (handler->second)
+ handler->second(error, response);
+ callbacks.erase(handler);
+ }
+
+ // We don't particularly care about wraparound issues.
+ while (!callbacks.empty() && callbacks.begin()->first <= command_seq) {
+ auto front = callbacks.begin();
+ if (front->second)
+ front->second(L"No response", nullptr);
+ callbacks.erase(front);
+ }
+}
+
+static void
+relay_process_message(const Relay::EventMessage &m)
+{
+ switch (m.data->event) {
+ case Relay::Event::ERROR:
+ {
+ auto data = dynamic_cast<Relay::EventData_Error *>(m.data.get());
+ relay_process_callbacks(data->command_seq, data->error, nullptr);
+ break;
+ }
+ case Relay::Event::RESPONSE:
+ {
+ auto data = dynamic_cast<Relay::EventData_Response *>(m.data.get());
+ relay_process_callbacks(data->command_seq, {}, data->data.get());
+ break;
+ }
+
+ case Relay::Event::PING:
+ {
+ auto pong = new Relay::CommandData_PingResponse();
+ pong->event_seq = m.event_seq;
+ relay_send(pong);
+ break;
+ }
+
+ case Relay::Event::BUFFER_LINE:
+ {
+ auto &data = dynamic_cast<Relay::EventData_BufferLine &>(*m.data);
+ auto b = buffer_by_name(data.buffer_name);
+ if (!b)
+ break;
+
+ relay_process_buffer_line(*b, data);
+ break;
+ }
+ case Relay::Event::BUFFER_UPDATE:
+ {
+ auto &data = dynamic_cast<Relay::EventData_BufferUpdate &>(*m.data);
+ auto b = buffer_by_name(data.buffer_name);
+ if (!b) {
+ b = &*g.buffers.insert(g.buffers.end(), Buffer());
+ b->buffer_name = QString::fromStdWString(data.buffer_name);
+
+ auto item = new QListWidgetItem;
+ refresh_buffer_list_item(item, *b);
+ g.wBufferList->addItem(item);
+ }
+
+ bool hiding_toggled = b->hide_unimportant != data.hide_unimportant;
+ b->hide_unimportant = data.hide_unimportant;
+ b->kind = data.context->kind;
+ b->server_name.clear();
+ if (auto context = dynamic_cast<Relay::BufferContext_Server *>(
+ data.context.get()))
+ b->server_name = QString::fromStdWString(context->server_name);
+ if (auto context = dynamic_cast<Relay::BufferContext_Channel *>(
+ data.context.get())) {
+ b->server_name = QString::fromStdWString(context->server_name);
+ b->modes = QString::fromStdWString(context->modes);
+ b->topic = convert_items(context->topic);
+ }
+ if (auto context = dynamic_cast<Relay::BufferContext_PrivateMessage *>(
+ data.context.get()))
+ b->server_name = QString::fromStdWString(context->server_name);
+
+ if (b->buffer_name == g.buffer_current) {
+ refresh_topic(b->topic);
+ refresh_status();
+
+ if (hiding_toggled)
+ refresh_buffer(*b);
+ }
+ break;
+ }
+ case Relay::Event::BUFFER_STATS:
+ {
+ auto &data = dynamic_cast<Relay::EventData_BufferStats &>(*m.data);
+ auto b = buffer_by_name(data.buffer_name);
+ if (!b)
+ break;
+
+ b->new_messages = data.new_messages;
+ b->new_unimportant_messages = data.new_unimportant_messages;
+ b->highlighted = data.highlighted;
+
+ refresh_icon();
+ refresh_buffer_list();
+ break;
+ }
+ case Relay::Event::BUFFER_RENAME:
+ {
+ auto &data = dynamic_cast<Relay::EventData_BufferRename &>(*m.data);
+ auto b = buffer_by_name(data.buffer_name);
+ if (!b)
+ break;
+
+ auto original = b->buffer_name;
+ b->buffer_name = QString::fromStdWString(data.new_);
+
+ if (original == g.buffer_current) {
+ g.buffer_current = b->buffer_name;
+ refresh_status();
+ }
+ refresh_buffer_list();
+ if (original == g.buffer_last)
+ g.buffer_last = b->buffer_name;
+ break;
+ }
+ case Relay::Event::BUFFER_REMOVE:
+ {
+ auto &data = dynamic_cast<Relay::EventData_BufferRemove &>(*m.data);
+ auto b = buffer_by_name(data.buffer_name);
+ if (!b)
+ break;
+
+ int index = b - g.buffers.data();
+ delete g.wBufferList->takeItem(index);
+ g.buffers.erase(g.buffers.begin() + index);
+
+ refresh_icon();
+ break;
+ }
+ case Relay::Event::BUFFER_ACTIVATE:
+ {
+ auto &data = dynamic_cast<Relay::EventData_BufferActivate &>(*m.data);
+ Buffer *old = buffer_by_name(g.buffer_current);
+ g.buffer_last = g.buffer_current;
+ g.buffer_current = QString::fromStdWString(data.buffer_name);
+ auto b = buffer_by_name(data.buffer_name);
+ if (!b)
+ break;
+
+ if (old) {
+ old->new_messages = 0;
+ old->new_unimportant_messages = 0;
+ old->highlighted = false;
+
+ old->input = g.wInput->toHtml();
+ old->input_start = g.wInput->textCursor().selectionStart();
+ old->input_end = g.wInput->textCursor().selectionEnd();
+
+ // Note that we effectively overwrite the newest line
+ // with the current textarea contents, and jump there.
+ old->history_at = old->history.size();
+ }
+
+ if (g.wLog->isVisible())
+ buffer_toggle_log();
+ if (!g.wMain->isMinimized())
+ b->highlighted = false;
+ auto item = g.wBufferList->item(b - g.buffers.data());
+ refresh_buffer_list_item(item, *b);
+ g.wBufferList->setCurrentItem(item);
+
+ refresh_icon();
+ refresh_topic(b->topic);
+ refresh_buffer(*b);
+ refresh_prompt();
+ refresh_status();
+
+ g.wInput->setHtml(b->input);
+ g.wInput->textCursor().setPosition(b->input_start);
+ g.wInput->textCursor().setPosition(
+ b->input_end, QTextCursor::KeepAnchor);
+ g.wInput->setFocus();
+ break;
+ }
+ case Relay::Event::BUFFER_INPUT:
+ {
+ auto &data = dynamic_cast<Relay::EventData_BufferInput &>(*m.data);
+ auto b = buffer_by_name(data.buffer_name);
+ if (!b)
+ break;
+
+ if (b->history_at == b->history.size())
+ b->history_at++;
+ b->history.push_back(
+ irc_to_rich_text(QString::fromStdWString(data.text)));
+ break;
+ }
+ case Relay::Event::BUFFER_CLEAR:
+ {
+ auto &data = dynamic_cast<Relay::EventData_BufferClear &>(*m.data);
+ auto b = buffer_by_name(data.buffer_name);
+ if (!b)
+ break;
+
+ b->lines.clear();
+ if (b->buffer_name == g.buffer_current)
+ refresh_buffer(*b);
+ break;
+ }
+
+ case Relay::Event::SERVER_UPDATE:
+ {
+ auto &data = dynamic_cast<Relay::EventData_ServerUpdate &>(*m.data);
+ auto name = QString::fromStdWString(data.server_name);
+ if (!g.servers.count(name))
+ g.servers.emplace(name, Server());
+
+ auto &server = g.servers.at(name);
+ server.state = data.data->state;
+
+ server.user.clear();
+ server.user_modes.clear();
+ if (auto registered = dynamic_cast<Relay::ServerData_Registered *>(
+ data.data.get())) {
+ server.user = QString::fromStdWString(registered->user);
+ server.user_modes = QString::fromStdWString(registered->user_modes);
+ }
+
+ refresh_prompt();
+ break;
+ }
+ case Relay::Event::SERVER_RENAME:
+ {
+ auto &data = dynamic_cast<Relay::EventData_ServerRename &>(*m.data);
+ auto original = QString::fromStdWString(data.server_name);
+ g.servers.insert_or_assign(
+ QString::fromStdWString(data.new_), g.servers.at(original));
+ g.servers.erase(original);
+ break;
+ }
+ case Relay::Event::SERVER_REMOVE:
+ {
+ auto &data = dynamic_cast<Relay::EventData_ServerRemove &>(*m.data);
+ auto name = QString::fromStdWString(data.server_name);
+ g.servers.erase(name);
+ break;
+ }
+ }
+}
+
+// --- Networking --------------------------------------------------------------
+
+static void
+relay_show_dialog()
+{
+ g.wConnectHost->setText(g.host);
+ g.wConnectPort->setText(g.port);
+ g.wConnectDialog->move(
+ g.wMain->frameGeometry().center() - g.wConnectDialog->rect().center());
+ switch (g.wConnectDialog->exec()) {
+ case QDialog::Accepted:
+ g.host = g.wConnectHost->text();
+ g.port = g.wConnectPort->text();
+ g.socket->connectToHost(g.host, g.port.toUShort());
+ break;
+ case QDialog::Rejected:
+ QCoreApplication::exit();
+ }
+}
+
+static void
+relay_process_error([[maybe_unused]] QAbstractSocket::SocketError err)
+{
+ show_error_message(g.socket->errorString());
+ g.socket->abort();
+ QTimer::singleShot(0, relay_show_dialog);
+}
+
+static void
+relay_process_connected()
+{
+ g.command_seq = 0;
+ g.command_callbacks.clear();
+
+ g.buffers.clear();
+ g.buffer_current.clear();
+ g.buffer_last.clear();
+ g.servers.clear();
+
+ refresh_icon();
+ refresh_topic({});
+ g.wBufferList->clear();
+ g.wBuffer->clear();
+ refresh_prompt();
+ refresh_status();
+
+ auto hello = new Relay::CommandData_Hello();
+ hello->version = Relay::VERSION;
+ relay_send(hello);
+}
+
+static bool
+relay_process_buffer(QString &error)
+{
+ // How I wish I could access the internal read buffer directly.
+ auto s = g.socket;
+ union {
+ uint32_t frame_len = 0;
+ char buf[sizeof frame_len];
+ };
+ while (s->peek(buf, sizeof buf) == sizeof buf) {
+ frame_len = qFromBigEndian(frame_len);
+ if (s->bytesAvailable() < qint64(sizeof frame_len + frame_len))
+ break;
+
+ s->skip(sizeof frame_len);
+ auto b = s->read(frame_len);
+ LibertyXDR::Reader r;
+ r.data = reinterpret_cast<const uint8_t *>(b.data());
+ r.length = b.size();
+
+ Relay::EventMessage m = {};
+ if (!m.deserialize(r) || r.length) {
+ error = "Deserialization failed.";
+ return false;
+ }
+
+ relay_process_message(m);
+ }
+ return true;
+}
+
+static void
+relay_process_ready()
+{
+ QString err;
+ if (!relay_process_buffer(err)) {
+ show_error_message(err);
+ g.socket->abort();
+ QTimer::singleShot(0, relay_show_dialog);
+ }
+}
+
+// --- Input line --------------------------------------------------------------
+
+static void
+input_set_contents(const QString &input)
+{
+ g.wInput->setHtml(input);
+
+ auto cursor = g.wInput->textCursor();
+ cursor.movePosition(QTextCursor::End);
+ g.wInput->setTextCursor(cursor);
+ g.wInput->ensureCursorVisible();
+}
+
+static bool
+input_submit()
+{
+ auto b = buffer_by_name(g.buffer_current);
+ if (!b)
+ return false;
+
+ auto input = new Relay::CommandData_BufferInput();
+ input->buffer_name = b->buffer_name.toStdWString();
+ input->text = rich_text_to_irc(g.wInput).toStdWString();
+
+ // Buffer::history[Buffer::history.size()] is virtual,
+ // and is represented either by edit contents when it's currently
+ // being edited, or by Buffer::input in all other cases.
+ b->history.push_back(g.wInput->toHtml());
+ b->history_at = b->history.size();
+ input_set_contents("");
+
+ relay_send(input);
+ return true;
+}
+
+struct InputStamp {
+ int start = {};
+ int end = {};
+ QString input;
+};
+
+static InputStamp
+input_stamp()
+{
+ // Hopefully, the selection markers match the plain text characters.
+ auto start = g.wInput->textCursor().selectionStart();
+ auto end = g.wInput->textCursor().selectionEnd();
+ return {start, end, g.wInput->toPlainText()};
+}
+
+static void
+input_complete(const InputStamp &state, const std::wstring &error,
+ const Relay::ResponseData_BufferComplete *response)
+{
+ if (!response) {
+ show_error_message(QString::fromStdWString(error));
+ return;
+ }
+
+ auto utf8 = state.input.sliced(0, state.start).toUtf8();
+ auto preceding = QString(utf8.sliced(0, response->start));
+ if (response->completions.size() > 0) {
+ auto insert = response->completions.at(0);
+ if (response->completions.size() == 1)
+ insert += L" ";
+
+ auto cursor = g.wInput->textCursor();
+ cursor.setPosition(preceding.length());
+ cursor.setPosition(state.end, QTextCursor::KeepAnchor);
+ cursor.insertHtml(irc_to_rich_text(QString::fromStdWString(insert)));
+ }
+
+ if (response->completions.size() != 1)
+ beep();
+
+ // TODO(p): Show all completion options.
+}
+
+static bool
+input_complete()
+{
+ // TODO(p): Also add an increasing counter to the stamp.
+ auto state = input_stamp();
+ if (state.start != state.end)
+ return false;
+
+ auto utf8 = state.input.sliced(0, state.start).toUtf8();
+ auto complete = new Relay::CommandData_BufferComplete();
+ complete->buffer_name = g.buffer_current.toStdWString();
+ complete->text = state.input.toStdWString();
+ complete->position = utf8.size();
+ relay_send(complete, [state](auto error, auto response) {
+ auto stamp = input_stamp();
+ if (std::make_tuple(stamp.start, stamp.end, stamp.input) !=
+ std::make_tuple(state.start, state.end, state.input))
+ return;
+ input_complete(stamp, error,
+ dynamic_cast<const Relay::ResponseData_BufferComplete *>(response));
+ });
+ return true;
+}
+
+static bool
+input_up()
+{
+ auto b = buffer_by_name(g.buffer_current);
+ if (!b || b->history_at < 1)
+ return false;
+
+ if (b->history_at == b->history.size())
+ b->input = g.wInput->toHtml();
+ input_set_contents(b->history.at(--b->history_at));
+ return true;
+}
+
+static bool
+input_down()
+{
+ auto b = buffer_by_name(g.buffer_current);
+ if (!b || b->history_at >= b->history.size())
+ return false;
+
+ input_set_contents(++b->history_at == b->history.size()
+ ? b->input
+ : b->history.at(b->history_at));
+ return true;
+}
+
+class InputEdit : public QTextEdit {
+ Q_OBJECT
+
+public:
+ explicit InputEdit(QWidget *parent = nullptr) : QTextEdit(parent) {}
+
+ void keyPressEvent(QKeyEvent *event) override
+ {
+ auto scrollable = g.wLog->isVisible()
+ ? g.wLog->verticalScrollBar()
+ : g.wBuffer->verticalScrollBar();
+
+ QKeyCombination combo(
+ event->modifiers() & ~Qt::KeypadModifier, Qt::Key(event->key()));
+ switch (combo.toCombined()) {
+ case Qt::Key_Return:
+ case Qt::Key_Enter:
+ input_submit();
+ break;
+ case QKeyCombination(Qt::ShiftModifier, Qt::Key_Return).toCombined():
+ case QKeyCombination(Qt::ShiftModifier, Qt::Key_Enter).toCombined():
+ // Qt amazingly inserts U+2028 LINE SEPARATOR instead.
+ this->textCursor().insertText("\n");
+ break;
+ case Qt::Key_Tab:
+ input_complete();
+ break;
+ case QKeyCombination(Qt::AltModifier, Qt::Key_P).toCombined():
+ case Qt::Key_Up:
+ input_up();
+ break;
+ case QKeyCombination(Qt::AltModifier, Qt::Key_N).toCombined():
+ case Qt::Key_Down:
+ input_down();
+ break;
+ case Qt::Key_PageUp:
+ scrollable->setValue(scrollable->value() - scrollable->pageStep());
+ break;
+ case Qt::Key_PageDown:
+ scrollable->setValue(scrollable->value() + scrollable->pageStep());
+ break;
+
+ default:
+ QTextEdit::keyPressEvent(event);
+ return;
+ }
+ event->accept();
+ }
+};
+
+// --- General UI --------------------------------------------------------------
+
+class BufferEdit : public QTextBrowser {
+ Q_OBJECT
+
+public:
+ explicit BufferEdit(QWidget *parent = nullptr) : QTextBrowser(parent) {}
+
+ void resizeEvent(QResizeEvent *event) override
+ {
+ bool to_bottom = buffer_at_bottom();
+ QTextBrowser::resizeEvent(event);
+ if (to_bottom) {
+ buffer_scroll_to_bottom();
+ } else {
+ recheck_highlighted();
+ refresh_status();
+ }
+ }
+};
+
+static void
+build_main_window()
+{
+ g.wMain = new QMainWindow;
+ refresh_icon();
+
+ auto central = new QWidget(g.wMain);
+ auto vbox = new QVBoxLayout(central);
+ vbox->setContentsMargins(4, 4, 4, 4);
+
+ g.wTopic = new QLabel(central);
+ g.wTopic->setTextFormat(Qt::RichText);
+ vbox->addWidget(g.wTopic);
+
+ auto splitter = new QSplitter(Qt::Horizontal, central);
+ splitter->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
+
+ g.wBufferList = new QListWidget(splitter);
+ g.wBufferList->setSizePolicy(
+ QSizePolicy::Preferred, QSizePolicy::Expanding);
+ QObject::connect(g.wBufferList, &QListWidget::currentRowChanged,
+ [](int row) {
+ if (row >= 0 && (size_t) row < g.buffers.size())
+ buffer_activate(g.buffers.at(row).buffer_name);
+ });
+
+ g.wStack = new QStackedWidget(splitter);
+
+ g.wBuffer = new BufferEdit(g.wStack);
+ g.wBuffer->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
+ g.wBuffer->setReadOnly(true);
+ g.wBuffer->setTextInteractionFlags(
+ Qt::TextSelectableByMouse | Qt::TextSelectableByKeyboard |
+ Qt::LinksAccessibleByMouse | Qt::LinksAccessibleByKeyboard);
+ g.wBuffer->setOpenExternalLinks(true);
+ QObject::connect(g.wBuffer->verticalScrollBar(), &QScrollBar::valueChanged,
+ []([[maybe_unused]] int value) {
+ recheck_highlighted();
+ refresh_status();
+ });
+ QObject::connect(g.wBuffer->verticalScrollBar(), &QScrollBar::rangeChanged,
+ []([[maybe_unused]] int min, [[maybe_unused]] int max) {
+ recheck_highlighted();
+ refresh_status();
+ });
+
+ g.wLog = new QTextBrowser(g.wStack);
+ g.wLog->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
+ g.wLog->setReadOnly(true);
+ g.wLog->setTextInteractionFlags(
+ Qt::TextSelectableByMouse | Qt::TextSelectableByKeyboard |
+ Qt::LinksAccessibleByMouse | Qt::LinksAccessibleByKeyboard);
+ g.wLog->setOpenExternalLinks(true);
+
+ g.wStack->addWidget(g.wBuffer);
+ g.wStack->addWidget(g.wLog);
+
+ splitter->addWidget(g.wBufferList);
+ splitter->setStretchFactor(0, 1);
+ splitter->addWidget(g.wStack);
+ splitter->setStretchFactor(1, 2);
+ vbox->addWidget(splitter);
+
+ auto hbox = new QHBoxLayout();
+ g.wPrompt = new QLabel(central);
+ hbox->addWidget(g.wPrompt);
+
+ g.wButtonB = new QToolButton(central);
+ g.wButtonB->setText("&B");
+ g.wButtonB->setCheckable(true);
+ hbox->addWidget(g.wButtonB);
+ g.wButtonI = new QToolButton(central);
+ g.wButtonI->setText("&I");
+ g.wButtonI->setCheckable(true);
+ hbox->addWidget(g.wButtonI);
+ g.wButtonU = new QToolButton(central);
+ g.wButtonU->setText("&U");
+ g.wButtonU->setCheckable(true);
+ hbox->addWidget(g.wButtonU);
+
+ g.wStatus = new QLabel(central);
+ g.wStatus->setAlignment(
+ Qt::AlignRight | Qt::AlignTrailing | Qt::AlignVCenter);
+ hbox->addWidget(g.wStatus);
+
+ g.wButtonLog = new QToolButton(central);
+ g.wButtonLog->setText("&Log");
+ g.wButtonLog->setCheckable(true);
+ QObject::connect(g.wButtonLog, &QToolButton::clicked,
+ []([[maybe_unused]] bool checked) { buffer_toggle_log(); });
+ hbox->addWidget(g.wButtonLog);
+
+ g.wButtonDown = new QToolButton(central);
+ g.wButtonDown->setIcon(
+ QApplication::style()->standardIcon(QStyle::SP_ArrowDown));
+ g.wButtonDown->setToolButtonStyle(Qt::ToolButtonIconOnly);
+ QObject::connect(g.wButtonDown, &QToolButton::clicked,
+ []([[maybe_unused]] bool checked) { buffer_scroll_to_bottom(); });
+ hbox->addWidget(g.wButtonDown);
+ vbox->addLayout(hbox);
+
+ g.wInput = new InputEdit(central);
+ g.wInput->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Minimum);
+ g.wInput->setMaximumHeight(50);
+ vbox->addWidget(g.wInput);
+
+ // TODO(p): Figure out why this is not reliable.
+ QObject::connect(g.wInput, &QTextEdit::currentCharFormatChanged,
+ [](const QTextCharFormat &format) {
+ g.wButtonB->setChecked(format.fontWeight() >= QFont::Bold);
+ g.wButtonI->setChecked(format.fontItalic());
+ g.wButtonU->setChecked(format.fontUnderline());
+ });
+
+ QObject::connect(g.wButtonB, &QToolButton::clicked,
+ [](bool checked) {
+ auto cursor = g.wInput->textCursor();
+ auto format = cursor.charFormat();
+ format.setFontWeight(checked ? QFont::Bold : QFont::Normal);
+ cursor.mergeCharFormat(format);
+ g.wInput->setTextCursor(cursor);
+ });
+ QObject::connect(g.wButtonI, &QToolButton::clicked,
+ [](bool checked) {
+ auto cursor = g.wInput->textCursor();
+ auto format = cursor.charFormat();
+ format.setFontItalic(checked);
+ cursor.mergeCharFormat(format);
+ g.wInput->setTextCursor(cursor);
+ });
+ QObject::connect(g.wButtonU, &QToolButton::clicked,
+ [](bool checked) {
+ auto cursor = g.wInput->textCursor();
+ auto format = cursor.charFormat();
+ format.setFontUnderline(checked);
+ cursor.mergeCharFormat(format);
+ g.wInput->setTextCursor(cursor);
+ });
+
+ central->setLayout(vbox);
+ g.wMain->setCentralWidget(central);
+ g.wMain->show();
+}
+
+static void
+build_connect_dialog()
+{
+ auto dialog = g.wConnectDialog = new QDialog(g.wMain);
+ dialog->setModal(true);
+ dialog->setWindowTitle("Connect to relay");
+
+ auto layout = new QFormLayout();
+ g.wConnectHost = new QLineEdit(dialog);
+ layout->addRow("&Host:", g.wConnectHost);
+ g.wConnectPort = new QLineEdit(dialog);
+ auto validator = new QIntValidator(0, 0xffff, g.wConnectDialog);
+ g.wConnectPort->setValidator(validator);
+ layout->addRow("&Port:", g.wConnectPort);
+
+ auto buttons = new QDialogButtonBox(dialog);
+ buttons->addButton(new QPushButton("&Connect", buttons),
+ QDialogButtonBox::AcceptRole);
+ buttons->addButton(new QPushButton("&Exit", buttons),
+ QDialogButtonBox::RejectRole);
+ QObject::connect(buttons, &QDialogButtonBox::accepted,
+ dialog, &QDialog::accept);
+ QObject::connect(buttons, &QDialogButtonBox::rejected,
+ dialog, &QDialog::reject);
+
+ auto vbox = new QVBoxLayout();
+ vbox->addLayout(layout);
+ vbox->addWidget(buttons);
+ dialog->setLayout(vbox);
+}
+
+// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+
+static std::vector<size_t>
+rotated_buffers()
+{
+ std::vector<size_t> rotated(g.buffers.size());
+ size_t start = 0;
+ for (auto it = g.buffers.begin(); it != g.buffers.end(); ++it)
+ if (it->buffer_name == g.buffer_current) {
+ start = it - g.buffers.begin();
+ break;
+ }
+ for (auto &index : rotated)
+ index = ++start % g.buffers.size();
+ return rotated;
+}
+
+static void
+bind_shortcuts()
+{
+ auto previous_buffer = [] {
+ auto rotated = rotated_buffers();
+ if (rotated.size() > 0) {
+ size_t i = (rotated.back() ? rotated.back() : g.buffers.size()) - 1;
+ buffer_activate(g.buffers[i].buffer_name);
+ }
+ };
+ auto next_buffer = [] {
+ auto rotated = rotated_buffers();
+ if (rotated.size() > 0)
+ buffer_activate(g.buffers[rotated.front()].buffer_name);
+ };
+ auto switch_buffer = [] {
+ if (auto b = buffer_by_name(g.buffer_last))
+ buffer_activate(b->buffer_name);
+ };
+ auto goto_highlight = [] {
+ for (auto i : rotated_buffers())
+ if (g.buffers[i].highlighted) {
+ buffer_activate(g.buffers[i].buffer_name);
+ break;
+ }
+ };
+ auto goto_activity = [] {
+ for (auto i : rotated_buffers())
+ if (g.buffers[i].new_messages) {
+ buffer_activate(g.buffers[i].buffer_name);
+ break;
+ }
+ };
+ auto toggle_unimportant = [] {
+ if (auto b = buffer_by_name(g.buffer_current))
+ buffer_toggle_unimportant(b->buffer_name);
+ };
+
+ new QShortcut(QKeyCombination(Qt::ControlModifier, Qt::Key_Tab),
+ g.wMain, switch_buffer);
+ new QShortcut(QKeyCombination(Qt::AltModifier, Qt::Key_Tab),
+ g.wMain, switch_buffer);
+
+ new QShortcut(QKeyCombination(Qt::NoModifier, Qt::Key_F5),
+ g.wMain, previous_buffer);
+ new QShortcut(QKeyCombination(Qt::AltModifier, Qt::Key_PageUp),
+ g.wMain, previous_buffer);
+ new QShortcut(QKeyCombination(Qt::ControlModifier, Qt::Key_PageUp),
+ g.wMain, previous_buffer);
+ new QShortcut(QKeyCombination(Qt::NoModifier, Qt::Key_F6),
+ g.wMain, next_buffer);
+ new QShortcut(QKeyCombination(Qt::AltModifier, Qt::Key_PageDown),
+ g.wMain, next_buffer);
+ new QShortcut(QKeyCombination(Qt::ControlModifier, Qt::Key_PageDown),
+ g.wMain, next_buffer);
+
+ new QShortcut(QKeyCombination(Qt::AltModifier, Qt::Key_A),
+ g.wMain, goto_activity);
+ new QShortcut(QKeyCombination(Qt::AltModifier, Qt::Key_Exclam),
+ g.wMain, goto_highlight);
+ new QShortcut(QKeyCombination(Qt::AltModifier, Qt::Key_H),
+ g.wMain, toggle_unimportant);
+}
+
+int
+main(int argc, char *argv[])
+{
+ QApplication app(argc, argv);
+ auto args = app.arguments();
+ if (args.size() != 1 && args.size() != 3) {
+ QMessageBox::critical(nullptr, "Error", "Usage: xT [HOST PORT]",
+ QMessageBox::Close);
+ return 1;
+ }
+
+ build_main_window();
+ build_connect_dialog();
+ bind_shortcuts();
+
+ g.date_change_timer = new QTimer(g.wMain);
+ g.date_change_timer->setSingleShot(true);
+ QObject::connect(g.date_change_timer, &QTimer::timeout, [] {
+ bool to_bottom = buffer_at_bottom();
+ buffer_print_and_watch_trailing_date_changes();
+ if (to_bottom)
+ buffer_scroll_to_bottom();
+ });
+
+ g.socket = new QTcpSocket(g.wMain);
+ QObject::connect(g.socket, &QTcpSocket::errorOccurred,
+ relay_process_error);
+ QObject::connect(g.socket, &QTcpSocket::connected,
+ relay_process_connected);
+ QObject::connect(g.socket, &QTcpSocket::readyRead,
+ relay_process_ready);
+ if (args.size() == 3) {
+ g.host = args[1];
+ g.port = args[2];
+ g.socket->connectToHost(g.host, g.port.toUShort());
+ } else {
+ // Allow it to center on its parent, which must be realized.
+ while (!g.wMain->windowHandle()->isExposed())
+ app.processEvents();
+ QTimer::singleShot(0, relay_show_dialog);
+ }
+
+ int result = app.exec();
+ delete g.wMain;
+ return result;
+}
+
+// Normally, QObjects should be placed in header files, which we don't do.
+#include "xT.moc"
diff --git a/xT/xT.desktop b/xT/xT.desktop
new file mode 100644
index 0000000..eeae4fd
--- /dev/null
+++ b/xT/xT.desktop
@@ -0,0 +1,8 @@
+[Desktop Entry]
+Type=Application
+Name=xT
+GenericName=IRC Client
+Icon=xT
+Exec=xT
+StartupNotify=false
+Categories=Network;Chat;IRCClient;
diff --git a/xT/xT.svg b/xT/xT.svg
new file mode 100644
index 0000000..0dd85bc
--- /dev/null
+++ b/xT/xT.svg
@@ -0,0 +1,29 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg version="1.1" width="48" height="48" viewBox="0 0 48 48"
+ xmlns="http://www.w3.org/2000/svg">
+
+ <defs>
+ <radialGradient id="grey-x">
+ <stop stop-color="hsl(66, 0%, 90%)" offset="0" />
+ <stop stop-color="hsl(66, 0%, 80%)" offset="1" />
+ </radialGradient>
+ <radialGradient id="orange">
+ <stop stop-color="hsl(36, 100%, 60%)" offset="0" />
+ <stop stop-color="hsl(23, 100%, 60%)" offset="1" />
+ </radialGradient>
+ <filter id="shadow" x="-50%" y="-50%" width="200%" height="200%">
+ <feDropShadow dx="0" dy="0" stdDeviation="0.05"
+ flood-color="rgba(0, 0, 0, .5)" />
+ </filter>
+ </defs>
+
+ <!-- XXX: librsvg screws up shadows on rotated objects. -->
+ <g filter="url(#shadow)" transform="translate(24 28) rotate(-45) scale(16)">
+ <path fill="url(#grey-x)" stroke="hsl(66, 0%, 30%)" stroke-width="0.1"
+ d="M-.25 -1 H.25 V-.25 H1 V.25 H.25 V1 H-.25 V.25 H-1 V-.25 H-.25 Z" />
+ </g>
+ <g filter="url(#shadow)" transform="translate(24 3) scale(16)">
+ <path fill="url(#orange)" stroke="hsl(36, 100%, 20%)" stroke-width="0.1"
+ d="M-.8 0 H.8 V.5 H.25 V2.625 H-.25 V.5 H-.8 Z" />
+ </g>
+</svg>
diff --git a/xT/xTq.cpp b/xT/xTq.cpp
new file mode 100644
index 0000000..a6d48bf
--- /dev/null
+++ b/xT/xTq.cpp
@@ -0,0 +1,40 @@
+/*
+ * xTq.cpp: Qt Quick frontend for xC
+ *
+ * Copyright (c) 2024, Přemysl Eric Janouch <p@janouch.name>
+ *
+ * Permission to use, copy, modify, and/or distribute this software for any
+ * purpose with or without fee is hereby granted.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY
+ * SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION
+ * OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN
+ * CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ *
+ */
+
+#include "xC-proto.cpp"
+
+#include <cstdint>
+
+#include <QGuiApplication>
+#include <QQmlApplicationEngine>
+
+#include "xTq.h"
+
+// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+
+int
+main(int argc, char *argv[])
+{
+ QGuiApplication app(argc, argv);
+
+ QQmlApplicationEngine engine;
+ QObject::connect(&engine, &QQmlApplicationEngine::objectCreationFailed,
+ &app, []() { QCoreApplication::exit(-1); }, Qt::QueuedConnection);
+ engine.loadFromModule("xTquick", "Main");
+ return app.exec();
+}
diff --git a/xT/xTq.h b/xT/xTq.h
new file mode 100644
index 0000000..70a0374
--- /dev/null
+++ b/xT/xTq.h
@@ -0,0 +1,15 @@
+#ifndef XTQ_H
+#define XTQ_H
+
+#include <QTcpSocket>
+#include <QtQmlIntegration/qqmlintegration.h>
+
+class RelayConnection : public QObject {
+ Q_OBJECT
+ QML_ELEMENT
+
+public:
+ QTcpSocket *socket; ///< Buffered relay socket
+};
+
+#endif // XTQ_H
diff --git a/xT/xTq.qml b/xT/xTq.qml
new file mode 100644
index 0000000..50063c9
--- /dev/null
+++ b/xT/xTq.qml
@@ -0,0 +1,105 @@
+import QtQuick
+import QtQuick.Controls.Fusion
+//import QtQuick.Controls
+import QtQuick.Layouts
+
+ApplicationWindow {
+ id: window
+ width: 640
+ height: 480
+ visible: true
+ title: qsTr("xT")
+
+ property RelayConnection connection
+
+ ColumnLayout {
+ id: column
+ anchors.fill: parent
+ anchors.margins: 6
+
+ ScrollView {
+ id: bufferScroll
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ TextArea {
+ id: buffer
+ text: qsTr("Buffer text")
+ }
+ }
+
+ RowLayout {
+ id: row
+ Layout.fillWidth: true
+
+ Label {
+ Layout.fillWidth: true
+ id: prompt
+ text: qsTr("Prompt")
+ }
+
+ Label {
+ Layout.fillWidth: true
+ id: status
+ horizontalAlignment: Text.AlignRight
+ text: qsTr("Status")
+ }
+ }
+
+ TextArea {
+ id: input
+ Layout.fillWidth: true
+ text: qsTr("Input")
+ }
+ }
+
+ Component.onCompleted: {}
+
+ Dialog {
+ id: connect
+ title: "Connect to relay"
+ anchors.centerIn: parent
+ modal: true
+ visible: true
+
+ onRejected: Qt.quit()
+ onAccepted: {
+ // TODO(p): Store the host, store the port, initiate connection.
+ }
+
+ GridLayout {
+ anchors.fill: parent
+ anchors.margins: 6
+ columns: 2
+
+ // It is a bit silly that one has to do everything manually.
+ Keys.onReturnPressed: connect.accept()
+
+ Label { text: "Host:" }
+ TextField {
+ id: connectHost
+ Layout.fillWidth: true
+ // And if this doesn't work reliably, do it after open().
+ focus: true
+ }
+ Label { text: "Port:" }
+ TextField {
+ id: connectPort
+ Layout.fillWidth: true
+ }
+ }
+
+ footer: DialogButtonBox {
+ Button {
+ text: qsTr("Connect")
+ DialogButtonBox.buttonRole: DialogButtonBox.AcceptRole
+ Keys.onReturnPressed: connect.accept()
+ highlighted: true
+ }
+ Button {
+ text: qsTr("Close")
+ DialogButtonBox.buttonRole: DialogButtonBox.DestructiveRole
+ Keys.onReturnPressed: connect.reject()
+ }
+ }
+ }
+}
diff --git a/xW/xW.cpp b/xW/xW.cpp
index b05eb37..0840c16 100644
--- a/xW/xW.cpp
+++ b/xW/xW.cpp
@@ -1,7 +1,7 @@
/*
* xW.cpp: Win32 frontend for xC
*
- * Copyright (c) 2023, Přemysl Eric Janouch <p@janouch.name>
+ * Copyright (c) 2023 - 2024, Přemysl Eric Janouch <p@janouch.name>
*
* Permission to use, copy, modify, and/or distribute this software for any
* purpose with or without fee is hereby granted.
@@ -222,6 +222,14 @@ relay_try_write(std::wstring &error)
}
static void
+on_relay_generic_response(
+ std::wstring error, const Relay::ResponseData *response)
+{
+ if (!response)
+ show_error_message(error.c_str());
+}
+
+static void
relay_send(Relay::CommandData *data, Callback callback = {})
{
Relay::CommandMessage m = {};
@@ -232,6 +240,8 @@ relay_send(Relay::CommandData *data, Callback callback = {})
if (callback)
g.command_callbacks[m.command_seq] = std::move(callback);
+ else
+ g.command_callbacks[m.command_seq] = on_relay_generic_response;
uint32_t len = htonl(w.data.size());
uint8_t *prefix = reinterpret_cast<uint8_t *>(&len);
@@ -255,73 +265,6 @@ buffer_by_name(const std::wstring &name)
return nullptr;
}
-static void
-buffer_activate(const std::wstring &name)
-{
- auto activate = new Relay::CommandData_BufferActivate();
- activate->buffer_name = name;
- relay_send(activate);
-}
-
-static void
-buffer_toggle_unimportant(const std::wstring &name)
-{
- auto toggle = new Relay::CommandData_BufferToggleUnimportant();
- toggle->buffer_name = name;
- relay_send(toggle);
-}
-
-// --- Current buffer ----------------------------------------------------------
-
-static void
-buffer_toggle_log(
- const std::wstring &error, const Relay::ResponseData_BufferLog *response)
-{
- if (!response) {
- show_error_message(error.c_str());
- return;
- }
-
- std::wstring log;
- if (!LibertyXDR::utf8_to_wstring(
- response->log.data(), response->log.size(), log)) {
- show_error_message(L"Invalid encoding.");
- return;
- }
-
- std::wstring filtered;
- for (auto wch : log) {
- if (wch == L'\n')
- filtered += L"\r\n";
- else
- filtered += wch;
- }
-
- SetWindowText(g.hwndBufferLog, filtered.c_str());
- ShowWindow(g.hwndBuffer, SW_HIDE);
- ShowWindow(g.hwndBufferLog, SW_SHOW);
-}
-
-static void
-buffer_toggle_log()
-{
- if (IsWindowVisible(g.hwndBufferLog)) {
- ShowWindow(g.hwndBufferLog, SW_HIDE);
- ShowWindow(g.hwndBuffer, SW_SHOW);
- SetWindowText(g.hwndBufferLog, L"");
- return;
- }
-
- auto log = new Relay::CommandData_BufferLog();
- log->buffer_name = g.buffer_current;
- relay_send(log, [name = g.buffer_current](auto error, auto response) {
- if (g.buffer_current != name)
- return;
- buffer_toggle_log(error,
- dynamic_cast<const Relay::ResponseData_BufferLog *>(response));
- });
-}
-
static bool
buffer_at_bottom()
{
@@ -354,6 +297,7 @@ refresh_icon()
if (b.highlighted)
icon = g.hiconHighlighted;
+ // XXX: This may not change the taskbar icon.
SendMessage(g.hwndMain, WM_SETICON, ICON_SMALL, (LPARAM) icon);
SendMessage(g.hwndMain, WM_SETICON, ICON_BIG, (LPARAM) icon);
}
@@ -430,6 +374,88 @@ refresh_status()
SetWindowText(g.hwndStatus, status.c_str());
}
+static void
+recheck_highlighted()
+{
+ // Corresponds to the logic toggling the bool on.
+ auto b = buffer_by_name(g.buffer_current);
+ if (b && b->highlighted && buffer_at_bottom() &&
+ !IsIconic(g.hwndMain) && !IsWindowVisible(g.hwndBufferLog)) {
+ b->highlighted = false;
+ refresh_icon();
+ refresh_buffer_list();
+ }
+}
+
+// --- Buffer actions ----------------------------------------------------------
+
+static void
+buffer_activate(const std::wstring &name)
+{
+ auto activate = new Relay::CommandData_BufferActivate();
+ activate->buffer_name = name;
+ relay_send(activate);
+}
+
+static void
+buffer_toggle_unimportant(const std::wstring &name)
+{
+ auto toggle = new Relay::CommandData_BufferToggleUnimportant();
+ toggle->buffer_name = name;
+ relay_send(toggle);
+}
+
+static void
+buffer_toggle_log(
+ const std::wstring &error, const Relay::ResponseData_BufferLog *response)
+{
+ if (!response) {
+ show_error_message(error.c_str());
+ return;
+ }
+
+ std::wstring log;
+ if (!LibertyXDR::utf8_to_wstring(
+ response->log.data(), response->log.size(), log)) {
+ show_error_message(L"Invalid encoding.");
+ return;
+ }
+
+ std::wstring filtered;
+ for (auto wch : log) {
+ if (wch == L'\n')
+ filtered += L"\r\n";
+ else
+ filtered += wch;
+ }
+
+ SetWindowText(g.hwndBufferLog, filtered.c_str());
+ ShowWindow(g.hwndBuffer, SW_HIDE);
+ ShowWindow(g.hwndBufferLog, SW_SHOW);
+}
+
+static void
+buffer_toggle_log()
+{
+ if (IsWindowVisible(g.hwndBufferLog)) {
+ ShowWindow(g.hwndBufferLog, SW_HIDE);
+ ShowWindow(g.hwndBuffer, SW_SHOW);
+ SetWindowText(g.hwndBufferLog, L"");
+
+ recheck_highlighted();
+ return;
+ }
+
+ auto log = new Relay::CommandData_BufferLog();
+ log->buffer_name = g.buffer_current;
+ relay_send(log, [name = g.buffer_current](auto error, auto response) {
+ if (g.buffer_current != name)
+ return;
+ buffer_toggle_log(error,
+ dynamic_cast<const Relay::ResponseData_BufferLog *>(response));
+ });
+}
+
// --- Rich Edit formatting ----------------------------------------------------
static COLORREF
@@ -695,7 +721,7 @@ buffer_print_line(std::vector<BufferLine>::const_iterator begin,
static void
buffer_print_separator()
{
- bool sameline = !GetWindowTextLength(g.hwndBuffer);
+ bool sameline = !buffer_reset_selection();
CHARFORMAT2 format = default_charformat();
format.dwEffects &= ~CFE_AUTOCOLOR;
@@ -728,6 +754,7 @@ refresh_buffer(const Buffer &b)
buffer_print_and_watch_trailing_date_changes();
buffer_scroll_to_bottom();
+ // We will get a scroll event, so no need to recheck_highlighted() here.
SendMessage(g.hwndBuffer, WM_SETREDRAW, (WPARAM) TRUE, 0);
InvalidateRect(g.hwndBuffer, NULL, TRUE);
@@ -749,8 +776,9 @@ relay_process_buffer_line(Buffer &b, Relay::EventData_BufferLine &m)
// Retained mode is complicated.
bool display = (!m.is_unimportant || !bc->hide_unimportant) &&
(b.buffer_name == g.buffer_current || m.leak_to_active);
+ // XXX: It would be great if it didn't autoscroll when focused.
bool to_bottom = display &&
- buffer_at_bottom();
+ (buffer_at_bottom() || GetFocus() == g.hwndBuffer);
bool visible = display &&
to_bottom &&
!IsIconic(g.hwndMain) &&
@@ -914,11 +942,11 @@ relay_process_message(const Relay::EventMessage &m)
b->buffer_name = data.new_;
- refresh_buffer_list();
if (data.buffer_name == g.buffer_current) {
g.buffer_current = data.new_;
refresh_status();
}
+ refresh_buffer_list();
if (data.buffer_name == g.buffer_last)
g.buffer_last = data.new_;
break;
@@ -1465,6 +1493,7 @@ richedit_proc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam,
{
// Dragging the scrollbar doesn't result in EN_VSCROLL.
LRESULT lResult = DefSubclassProc(hWnd, uMsg, wParam, lParam);
+ recheck_highlighted();
refresh_status();
return lResult;
}
@@ -1522,8 +1551,12 @@ process_resize(UINT w, UINT h)
MoveWindow(g.hwndBufferList, 3, top, 150, h - top - bottom, FALSE);
MoveWindow(g.hwndBuffer, 156, top, w - 159, h - top - bottom, FALSE);
MoveWindow(g.hwndBufferLog, 156, top, w - 159, h - top - bottom, FALSE);
- if (to_bottom)
+ if (to_bottom) {
buffer_scroll_to_bottom();
+ } else {
+ recheck_highlighted();
+ refresh_status();
+ }
InvalidateRect(g.hwndMain, NULL, TRUE);
}
@@ -1685,8 +1718,10 @@ window_proc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam)
}
case WM_SYSCOMMAND:
{
+ // We're not deiconified yet, so duplicate recheck_highlighted().
auto b = buffer_by_name(g.buffer_current);
- if (b && wParam == SC_RESTORE) {
+ if (wParam == SC_RESTORE && b && b->highlighted && buffer_at_bottom() &&
+ !IsWindowVisible(g.hwndBufferLog)) {
b->highlighted = false;
refresh_icon();
}
@@ -1694,13 +1729,15 @@ window_proc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam)
break;
}
case WM_COMMAND:
- if (!lParam)
+ if (!lParam) {
process_accelerator(LOWORD(wParam));
- else if (lParam == (LPARAM) g.hwndBufferList)
+ } else if (lParam == (LPARAM) g.hwndBufferList) {
process_bufferlist_notification(HIWORD(wParam));
- else if (lParam == (LPARAM) g.hwndBuffer &&
- HIWORD(wParam) == EN_VSCROLL)
+ } else if (lParam == (LPARAM) g.hwndBuffer &&
+ HIWORD(wParam) == EN_VSCROLL) {
+ recheck_highlighted();
refresh_status();
+ }
return 0;
case WM_NOTIFY:
switch (((LPNMHDR) lParam)->code) {