aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--CMakeLists.txt5
-rw-r--r--LICENSE2
-rw-r--r--NEWS22
-rw-r--r--README.adoc16
m---------liberty0
-rwxr-xr-xtest52
-rw-r--r--test.lua72
-rw-r--r--xA/go.mod49
-rw-r--r--xA/go.sum677
-rw-r--r--xA/xA.go192
-rw-r--r--xC.c840
-rw-r--r--xC.lxdr53
-rw-r--r--xF.c1088
-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/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
34 files changed, 4328 insertions, 1357 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 4a32576..d9699ca 100644
--- a/NEWS
+++ b/NEWS
@@ -1,7 +1,29 @@
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"
diff --git a/README.adoc b/README.adoc
index 172224a..1866b2a 100644
--- a/README.adoc
+++ b/README.adoc
@@ -33,10 +33,10 @@ including link:xC.adoc#_key_bindings[keyboard shortcuts].
image::xP.webp[align="center"]
-<<<<<<< HEAD
-xA, xF, xW, xM
---------------
-The native frontends for 'xC'. Using them is not recommended.
+xA, xT, xF, xW, xM
+------------------
+Fyne, Qt Widgets, X11, Win32, Cocoa frontends for 'xC'.
+Using them is not recommended.
xD
--
@@ -152,7 +152,13 @@ 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` first.
+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
~~
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/go.mod b/xA/go.mod
index b758d9e..49c25cc 100644
--- a/xA/go.mod
+++ b/xA/go.mod
@@ -1,41 +1,46 @@
module janouch.name/xK/xA
-go 1.22
+go 1.23.0
+
+toolchain go1.24.0
require (
- fyne.io/fyne/v2 v2.5.2
- github.com/ebitengine/oto/v3 v3.3.1
+ 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.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.3.0 // 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 7cdb3c6..3a6f7ce 100644
--- a/xA/go.sum
+++ b/xA/go.sum
@@ -1,659 +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/ebitengine/oto/v3 v3.3.1 h1:d4McwGQuXOT0GL7bA5g9ZnaUEIEjQvG3hafzMy+T3qE=
-github.com/ebitengine/oto/v3 v3.3.1/go.mod h1:MZeb/lwoC4DCOdiTIxYezrURTw7EvK/yF863+tmBI+U=
-github.com/ebitengine/purego v0.8.1 h1:sdRKd6plj7KYW33EH5As6YKfe8m9zbN9JMrOjNVF/BE=
-github.com/ebitengine/purego v0.8.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
-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.3.0 h1:QRHcwKwx3kY5JTQcsVhmhC3TGqGQb9LFghVNUy8AdB8=
-github.com/rymdport/portal v0.3.0/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 5bd3975..b4f796c 100644
--- a/xA/xA.go
+++ b/xA/xA.go
@@ -1,4 +1,4 @@
-// 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
@@ -122,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 &&
@@ -281,7 +285,11 @@ func beep() {
}
go func() {
<-otoReady
- otoContext.NewPlayer(bytes.NewReader(beepSample)).Play()
+ p := otoContext.NewPlayer(bytes.NewReader(beepSample))
+ p.Play()
+ for p.IsPlaying() {
+ time.Sleep(time.Second)
+ }
}()
}
@@ -329,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.
@@ -363,16 +376,18 @@ func bufferByName(name string) *buffer {
return nil
}
-func bufferActivate(name string) {
- relaySend(RelayCommandData{
- Variant: &RelayCommandDataBufferActivate{BufferName: name},
- }, nil)
+func bufferAtBottom() bool {
+ return wRichScroll.Offset.Y >=
+ wRichScroll.Content.Size().Height-wRichScroll.Size().Height
}
-func bufferToggleUnimportant(name string) {
- relaySend(RelayCommandData{
- Variant: &RelayCommandDataBufferToggleUnimportant{BufferName: name},
- }, nil)
+func bufferScrollToBottom() {
+ // XXX: Doing it once is not reliable, something's amiss.
+ // (In particular, nothing happens when we switch from an empty buffer
+ // to a buffer than needs scrolling.)
+ wRichScroll.ScrollToBottom()
+ wRichScroll.ScrollToBottom()
+ refreshStatus()
}
func bufferPushLine(b *buffer, line bufferLine) {
@@ -386,68 +401,17 @@ func bufferPushLine(b *buffer, line bufferLine) {
}
}
-// --- Current buffer ----------------------------------------------------------
-
-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("")
- return
- }
-
- name := bufferCurrent
- relaySend(RelayCommandData{Variant: &RelayCommandDataBufferLog{
- BufferName: name,
- }}, func(err string, response *RelayResponseData) {
- if bufferCurrent == name {
- bufferToggleLogFinish(
- err, response.Variant.(*RelayResponseDataBufferLog))
- }
- })
-}
-
-func bufferAtBottom() bool {
- return wRichScroll.Offset.Y >=
- wRichScroll.Content.Size().Height-wRichScroll.Size().Height
-}
-
-func bufferScrollToBottom() {
- // XXX: Doing it once is not reliable, something's amiss.
- // (In particular, nothing happens when we switch from an empty buffer
- // to a buffer than needs scrolling.)
- wRichScroll.ScrollToBottom()
- wRichScroll.ScrollToBottom()
- refreshStatus()
-}
-
// --- 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) {
@@ -515,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{} }
@@ -756,6 +777,7 @@ func refreshBuffer(b *buffer) {
bufferPrintAndWatchTrailingDateChanges()
wRichText.Refresh()
bufferScrollToBottom()
+ recheckHighlighted()
}
// --- Event processing --------------------------------------------------------
@@ -921,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
}
@@ -1078,7 +1100,10 @@ func relayRun() {
fyne.CurrentApp().Preferences().SetString(preferenceAddress, backendAddress)
backendLock.Lock()
- relayResetState()
+ fyne.DoAndWait(func() {
+ relayResetState()
+ })
+
backendContext, backendCancel = context.WithCancel(context.Background())
defer backendCancel()
var err error
@@ -1086,8 +1111,10 @@ func relayRun() {
backendLock.Unlock()
if err != nil {
- wConnect.Show()
- showErrorMessage("Connection failed: " + err.Error())
+ fyne.DoAndWait(func() {
+ wConnect.Show()
+ showErrorMessage("Connection failed: " + err.Error())
+ })
return
}
defer backendConn.Close()
@@ -1107,12 +1134,15 @@ Loop:
if !ok {
break Loop
}
- relayProcessMessage(&m)
+ fyne.DoAndWait(func() {
+ relayProcessMessage(&m)
+ })
}
}
-
- wConnect.Show()
- showErrorMessage("Disconnected")
+ fyne.DoAndWait(func() {
+ wConnect.Show()
+ showErrorMessage("Disconnected")
+ })
}
// --- Input line --------------------------------------------------------------
@@ -1374,6 +1404,9 @@ func (l *customLayout) Layout(objects []fyne.CanvasObject, size fyne.Size) {
}
if toBottom {
bufferScrollToBottom()
+ } else {
+ recheckHighlighted()
+ refreshStatus()
}
}
@@ -1490,16 +1523,14 @@ func main() {
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
@@ -1540,7 +1571,10 @@ func main() {
wRichText = widget.NewRichText()
wRichText.Wrapping = fyne.TextWrapWord
wRichScroll = container.NewVScroll(wRichText)
- wRichScroll.OnScrolled = func(position fyne.Position) { refreshStatus() }
+ wRichScroll.OnScrolled = func(position fyne.Position) {
+ recheckHighlighted()
+ refreshStatus()
+ }
wLog = newLogEntry()
wLog.Wrapping = fyne.TextWrapWord
wLog.Hide()
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/xF.c b/xF.c
index 054871d..98dfbf8 100644
--- a/xF.c
+++ b/xF.c
@@ -1,7 +1,7 @@
/*
* xF.c: a toothless IRC client frontend
*
- * Copyright (c) 2022, Přemysl Eric Janouch <p@janouch.name>
+ * Copyright (c) 2022 - 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.
@@ -22,21 +22,556 @@
#include "common.c"
#include "xC-proto.c"
+#define LIBERTY_XDG_WANT_X11
+#define LIBERTY_XDG_WANT_ICONS
+#include "liberty/liberty-xdg.c"
+
#include <X11/Xatom.h>
#include <X11/Xlib.h>
#include <X11/keysym.h>
#include <X11/XKBlib.h>
#include <X11/Xft/Xft.h>
+// --- Frontend ----------------------------------------------------------------
+
+// See: struct relay_event_data_buffer_line
+struct buffer_line
+{
+ LIST_HEADER (struct buffer_line)
+
+ /// Leaked from another buffer, but temporarily staying in another one.
+ bool leaked;
+
+ bool is_unimportant;
+ bool is_highlight;
+ enum relay_rendition rendition;
+ uint64_t when;
+ char text[];
+};
+
+// See: struct relay_event_data_buffer_update
+struct buffer
+{
+ LIST_HEADER (struct buffer)
+
+ char *buffer_name;
+ enum relay_buffer_kind kind;
+ char *server_name;
+ struct buffer_line *lines;
+ struct buffer_line *lines_tail;
+
+ // Stats:
+
+ uint32_t new_messages;
+ uint32_t new_unimportant_messages;
+ bool highlighted;
+};
+
+static void
+buffer_destroy (struct buffer *self)
+{
+ free (self->buffer_name);
+ free (self->server_name);
+ LIST_FOR_EACH (struct buffer_line, iter, self->lines)
+ free (iter);
+ free (self);
+}
+
+// See: struct relay_event_data_server_update
+struct server
+{
+ enum relay_server_state state;
+ char *user;
+ char *user_modes;
+};
+
+static void
+server_destroy (struct server *self)
+{
+ free (self->user);
+ free (self->user_modes);
+ free (self);
+}
+
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+/// Wraps Xft fonts into a linked list with fallbacks.
+struct x11_font_link
+{
+ struct x11_font_link *next;
+ XftFont *font;
+};
+
+enum
+{
+ X11_FONT_BOLD = 1 << 0,
+ X11_FONT_ITALIC = 1 << 1,
+ X11_FONT_MONOSPACE = 1 << 2,
+};
+
+struct x11_font
+{
+ struct x11_font *next; ///< Next in a linked list
+
+ struct x11_font_link *list; ///< Fonts of varying Unicode coverage
+ unsigned style; ///< X11_FONT_* flags
+ FcPattern *pattern; ///< Original unsubstituted pattern
+ FcCharSet *unavailable; ///< Couldn't find a font for these
+};
+
static struct
{
- bool polling;
+ struct poller poller; ///< Poller
+ bool polling; ///< The event loop is running
+
+ // Relay plumbing:
+
struct connector connector;
- int socket;
+ int socket_fd; ///< Backend TCP socket
+ struct poller_fd socket_event; ///< The socket can be read/written to
+ struct str read_buffer; ///< Unprocessed input
+ struct str write_buffer; ///< Output yet to be sent out
+
+ uint32_t command_seq; ///< Outgoing message counter
+
+ // Relay state:
+
+ struct buffer *buffers; ///< Ordered list of all buffers
+ struct buffer *buffers_tail; ///< The tail of all buffers
+ struct buffer *buffer_current; ///< The current buffer
+ struct buffer *buffer_last; ///< Last used buffer
+
+ struct str_map servers; ///< All servers
+
+ // User interface:
+
+ int ui_width; ///< Window width
+ int ui_height; ///< Window height
+ bool ui_focused; ///< Whether the window has focus
+
+ XIM x11_im; ///< Input method
+ XIC x11_ic; ///< Input method context
+ Display *dpy; ///< X display handle
+ struct poller_fd x11_event; ///< X11 events on wire
+ struct poller_idle xpending_event; ///< X11 events possibly in I/O queues
+ int xkb_base_event_code; ///< Xkb base event code
+ Window x11_window; ///< Application window
+ Pixmap x11_pixmap; ///< Off-screen bitmap
+ Region x11_clip; ///< Invalidated region
+ Picture x11_pixmap_picture; ///< XRender wrap for x11_pixmap
+ XftDraw *xft_draw; ///< Xft rendering context
+ struct x11_font *xft_fonts; ///< Font collection
+ char *x11_selection; ///< CLIPBOARD selection
+
+ const char *x11_fontname; ///< Fontconfig font name
+ const char *x11_fontname_monospace; ///< Fontconfig monospace font name
+
+ struct poller_idle refresh_event; ///< Refresh the window's contents
+ struct poller_idle flip_event; ///< Draw rendered widgets on screen
+}
+g =
+{
+ .x11_fontname = "sans\\-serif-11",
+ .x11_fontname_monospace = "monospace-11",
+};
+
+// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+
+static void
+app_init_context (void)
+{
+ poller_init (&g.poller);
+
+ g.socket_fd = -1;
+ g.read_buffer = str_make ();
+ g.write_buffer = str_make ();
+
+ g.servers = str_map_make ((str_map_free_fn) server_destroy);
+
+ // Presumably, although not necessarily; unsure if queryable at all
+ g.ui_focused = true;
+}
+
+static void
+app_quit (void)
+{
+ // So far there's nothing for us to wait on, so let's just stop looping.
+ g.polling = false;
+}
+
+static void
+app_invalidate (void)
+{
+ poller_idle_set (&g.refresh_event);
+}
+
+// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+
+static struct buffer *
+buffer_find (const char *name)
+{
+ LIST_FOR_EACH (struct buffer, buffer, g.buffers)
+ if (!strcmp (buffer->buffer_name, name))
+ return buffer;
+ return NULL;
+}
+
+static struct buffer_line *
+buffer_line_new (struct relay_event_data_buffer_line *m)
+{
+ struct str s = str_make ();
+ for (uint32_t i = 0; i < m->items_len; i++)
+ if (m->items[i].kind == RELAY_ITEM_TEXT)
+ str_append_str (&s, &m->items[i].text.text);
+
+ struct buffer_line *self = xcalloc (1, sizeof *self + s.len + 1);
+ memcpy (self->text, s.str, s.len + 1);
+ str_free (&s);
+
+ self->is_unimportant = m->is_unimportant;
+ self->is_highlight = m->is_highlight;
+ self->rendition = m->rendition;
+ self->when = m->when;
+ return self;
+}
+
+static void
+relay_process_buffer_line (struct buffer *buffer,
+ struct relay_event_data_buffer_line *m)
+{
+ // Initial sync: skip all other processing, let highlights be.
+ if (!g.buffer_current)
+ {
+ struct buffer_line *line = buffer_line_new (m);
+ LIST_APPEND_WITH_TAIL (buffer->lines, buffer->lines_tail, line);
+ return;
+ }
+
+ // TODO: Track window on-screen visibility.
+ bool visible = buffer == g.buffer_current || m->leak_to_active;
+
+ // TODO: Port the rest from xP.js.
+ struct buffer_line *line = buffer_line_new (m);
+ LIST_APPEND_WITH_TAIL (buffer->lines, buffer->lines_tail, line);
+ if (!(visible || m->leak_to_active)
+ || buffer->new_messages || buffer->new_unimportant_messages)
+ {
+ if (line->is_unimportant || m->leak_to_active)
+ buffer->new_unimportant_messages++;
+ else
+ buffer->new_messages++;
+ }
+
+ if (m->leak_to_active)
+ {
+ struct buffer *bc = g.buffer_current;
+ struct buffer_line *line = buffer_line_new (m);
+ line->leaked = true;
+ LIST_APPEND_WITH_TAIL (bc->lines, bc->lines_tail, line);
+ if (!visible || bc->new_messages || bc->new_unimportant_messages)
+ {
+ if (line->is_unimportant)
+ bc->new_unimportant_messages++;
+ else
+ bc->new_messages++;
+ }
+ }
+
+ if (line->is_highlight || (!visible && !line->is_unimportant
+ && buffer->kind == RELAY_BUFFER_KIND_PRIVATE_MESSAGE))
+ {
+ // TODO: Play a beep sample.
+ if (!visible)
+ buffer->highlighted = true;
+ }
+}
+
+static const char *
+relay_message_buffer_name (const struct relay_event_message *m)
+{
+ switch (m->data.event)
+ {
+ case RELAY_EVENT_BUFFER_LINE:
+ return m->data.buffer_line.buffer_name.str;
+ case RELAY_EVENT_BUFFER_UPDATE:
+ return m->data.buffer_update.buffer_name.str;
+ case RELAY_EVENT_BUFFER_STATS:
+ return m->data.buffer_stats.buffer_name.str;
+ case RELAY_EVENT_BUFFER_RENAME:
+ return m->data.buffer_rename.buffer_name.str;
+ case RELAY_EVENT_BUFFER_REMOVE:
+ return m->data.buffer_remove.buffer_name.str;
+ case RELAY_EVENT_BUFFER_ACTIVATE:
+ return m->data.buffer_activate.buffer_name.str;
+ case RELAY_EVENT_BUFFER_INPUT:
+ return m->data.buffer_input.buffer_name.str;
+ case RELAY_EVENT_BUFFER_CLEAR:
+ return m->data.buffer_clear.buffer_name.str;
+ default:
+ return NULL;
+ }
+}
+
+static const char *
+relay_message_server_name (const struct relay_event_message *m)
+{
+ switch (m->data.event)
+ {
+ case RELAY_EVENT_SERVER_UPDATE:
+ return m->data.server_update.server_name.str;
+ case RELAY_EVENT_SERVER_RENAME:
+ return m->data.server_rename.server_name.str;
+ case RELAY_EVENT_SERVER_REMOVE:
+ return m->data.server_remove.server_name.str;
+ default:
+ return NULL;
+ }
+}
+
+static const char *
+relay_buffer_update_server_name (const struct relay_event_data_buffer_update *e)
+{
+ switch (e->context.kind)
+ {
+ case RELAY_BUFFER_KIND_SERVER:
+ return e->context.server.server_name.str;
+ case RELAY_BUFFER_KIND_CHANNEL:
+ return e->context.channel.server_name.str;
+ case RELAY_BUFFER_KIND_PRIVATE_MESSAGE:
+ return e->context.private_message.server_name.str;
+ default:
+ return NULL;
+ }
+}
+
+static bool
+relay_process_message (struct msg_unpacker *r, struct relay_event_message *m)
+{
+ if (!relay_event_message_deserialize (m, r)
+ || msg_unpacker_get_available (r))
+ {
+ print_error ("deserialization failed");
+ return false;
+ }
+
+ const char *buffer_name = relay_message_buffer_name (m);
+ struct buffer *buffer = NULL;
+ if (buffer_name && !(buffer = buffer_find (buffer_name)))
+ {
+ // TODO: Maybe handle BUFFER_ACTIVATE the way xP does.
+ if (m->data.event != RELAY_EVENT_BUFFER_UPDATE)
+ {
+ print_warning ("unknown buffer: %s", buffer_name);
+ return true;
+ }
+
+ buffer = xcalloc (1, sizeof *buffer);
+ buffer->buffer_name = xstrdup (buffer_name);
+ LIST_APPEND_WITH_TAIL (g.buffers, g.buffers_tail, buffer);
+ }
+
+ const char *server_name = relay_message_server_name (m);
+ struct server *server = NULL;
+ if (server_name && !(server = str_map_find (&g.servers, server_name)))
+ {
+ if (m->data.event != RELAY_EVENT_SERVER_UPDATE)
+ {
+ print_warning ("unknown server: %s", server_name);
+ return true;
+ }
+
+ server = xcalloc (1, sizeof *server);
+ str_map_set (&g.servers, server_name, server);
+ }
+
+ switch (m->data.event)
+ {
+ case RELAY_EVENT_PING:
+ // TODO: While not important, we should implement it.
+ return true;
+
+ case RELAY_EVENT_BUFFER_LINE:
+ relay_process_buffer_line (buffer, &m->data.buffer_line);
+ break;
+ case RELAY_EVENT_BUFFER_UPDATE:
+ buffer->kind = m->data.buffer_update.context.kind;
+
+ server_name = relay_buffer_update_server_name (&m->data.buffer_update);
+ cstr_set (&buffer->server_name, NULL);
+ if (server_name)
+ buffer->server_name = xstrdup (server_name);
+
+ // TODO: More kind-dependent state.
+ break;
+ case RELAY_EVENT_BUFFER_STATS:
+ buffer->new_messages
+ = m->data.buffer_stats.new_messages;
+ buffer->new_unimportant_messages
+ = m->data.buffer_stats.new_unimportant_messages;
+ buffer->highlighted
+ = m->data.buffer_stats.highlighted;
+ break;
+ case RELAY_EVENT_BUFFER_RENAME:
+ free (buffer->buffer_name);
+ buffer->buffer_name = xstrdup (m->data.buffer_rename.new.str);
+ break;
+ case RELAY_EVENT_BUFFER_REMOVE:
+ LIST_UNLINK_WITH_TAIL (g.buffers, g.buffers_tail, buffer);
+ if (g.buffer_current == buffer)
+ g.buffer_current = NULL;
+ if (g.buffer_last == buffer)
+ g.buffer_last = NULL;
+ buffer_destroy (buffer);
+ break;
+ case RELAY_EVENT_BUFFER_ACTIVATE:
+ if (g.buffer_current)
+ {
+ g.buffer_current->new_messages = 0;
+ g.buffer_current->new_unimportant_messages = 0;
+ g.buffer_current->highlighted = false;
+ }
+
+ g.buffer_last = g.buffer_current;
+ g.buffer_current = buffer;
+ // TODO: Switch the input line.
+ break;
+ case RELAY_EVENT_BUFFER_CLEAR:
+ LIST_FOR_EACH (struct buffer_line, iter, buffer->lines)
+ free (iter);
+ buffer->lines = NULL;
+ break;
+
+ case RELAY_EVENT_SERVER_UPDATE:
+ server->state = m->data.server_update.data.state;
+
+ cstr_set (&server->user, NULL);
+ cstr_set (&server->user_modes, NULL);
+ if (server->state == RELAY_SERVER_STATE_REGISTERED)
+ {
+ server->user =
+ xstrdup (m->data.server_update.data.registered.user.str);
+ server->user_modes =
+ xstrdup (m->data.server_update.data.registered.user_modes.str);
+ }
+ break;
+ case RELAY_EVENT_SERVER_RENAME:
+ str_map_set (&g.servers, m->data.server_rename.new.str,
+ str_map_steal (&g.servers, server_name));
+ break;
+ case RELAY_EVENT_SERVER_REMOVE:
+ str_map_set (&g.servers, server_name, NULL);
+ break;
+
+ default:
+ return true;
+ }
+ app_invalidate ();
+ return true;
+}
+
+static bool
+relay_process_buffer (void)
+{
+ struct str *buf = &g.read_buffer;
+ size_t offset = 0;
+ while (true)
+ {
+ uint32_t frame_len = 0;
+ struct msg_unpacker r =
+ msg_unpacker_make (buf->str + offset, buf->len - offset);
+ if (!msg_unpacker_u32 (&r, &frame_len))
+ break;
+
+ r.len = MIN (r.len, sizeof frame_len + frame_len);
+ if (msg_unpacker_get_available (&r) < frame_len)
+ break;
+
+ struct relay_event_message m = {};
+ bool ok = relay_process_message (&r, &m);
+ relay_event_message_free (&m);
+ if (!ok)
+ return false;
+
+ offset += r.offset;
+ }
+
+ str_remove_slice (buf, 0, offset);
+ return true;
+}
+
+static bool
+relay_try_read (void)
+{
+ struct str *buf = &g.read_buffer;
+ ssize_t n_read;
+
+ while ((n_read = read (g.socket_fd, buf->str + buf->len,
+ buf->alloc - buf->len - 1 /* null byte */)) > 0)
+ {
+ buf->len += n_read;
+ if (!relay_process_buffer ())
+ break;
+ str_reserve (buf, 512);
+ }
+
+ if (n_read < 0)
+ {
+ if (errno == EAGAIN || errno == EINTR)
+ return true;
+
+ print_debug ("%s: %s: %s", __func__, "read", strerror (errno));
+ }
+ return false;
}
-g;
+
+static bool
+relay_try_write (void)
+{
+ struct str *buf = &g.write_buffer;
+ ssize_t n_written;
+
+ while (buf->len)
+ {
+ n_written = write (g.socket_fd, buf->str, buf->len);
+ if (n_written >= 0)
+ {
+ str_remove_slice (buf, 0, n_written);
+ continue;
+ }
+ if (errno == EAGAIN || errno == EINTR)
+ return true;
+
+ print_debug ("%s: %s: %s", __func__, "write", strerror (errno));
+ return false;
+ }
+ return true;
+}
+
+static void
+relay_update_poller (const struct pollfd *pfd)
+{
+ int new_events = POLLIN;
+ if (g.write_buffer.len)
+ new_events |= POLLOUT;
+
+ hard_assert (new_events != 0);
+ if (!pfd || pfd->events != new_events)
+ poller_fd_set (&g.socket_event, new_events);
+}
+
+static void
+on_relay_ready (const struct pollfd *pfd, void *user_data)
+{
+ if (relay_try_read () && relay_try_write ())
+ relay_update_poller (pfd);
+ else
+ {
+ // TODO: Probably autoreconnect.
+ exit_fatal ("disconnected");
+ }
+}
+
+// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
static void
on_connector_connecting (void *user_data, const char *address)
@@ -64,65 +599,526 @@ on_connector_connected (void *user_data, int socket, const char *hostname)
{
(void) user_data;
(void) hostname;
- g.polling = false;
- g.socket = socket;
+ connector_free (&g.connector);
+
+ set_blocking (socket, false);
+ set_cloexec (socket);
+
+ // We already buffer our output, so reduce latencies.
+ int yes = 1;
+ soft_assert (setsockopt (socket, IPPROTO_TCP, TCP_NODELAY,
+ &yes, sizeof yes) != -1);
+
+ g.socket_fd = socket;
+ g.socket_event = poller_fd_make (&g.poller, g.socket_fd);
+ g.socket_event.dispatcher = (poller_fd_fn) on_relay_ready;
+
+ str_reset (&g.read_buffer);
+ str_reset (&g.write_buffer);
+
+ struct str *s = &g.write_buffer;
+ str_pack_u32 (s, 0);
+ struct relay_command_message m = {};
+ m.command_seq = ++g.command_seq;
+ m.data.hello.command = RELAY_COMMAND_HELLO;
+ m.data.hello.version = RELAY_VERSION;
+ if (!relay_command_message_serialize (&m, s))
+ exit_fatal ("serialization failed");
+
+ uint32_t len = htonl (s->len - sizeof len);
+ memcpy (s->str, &len, sizeof len);
+
+ relay_update_poller (NULL);
}
static void
-protocol_test (const char *host, const char *port)
+relay_connect (const char *host, const char *port)
{
- struct poller poller = {};
- poller_init (&poller);
-
- connector_init (&g.connector, &poller);
+ connector_init (&g.connector, &g.poller);
g.connector.on_connecting = on_connector_connecting;
g.connector.on_error = on_connector_error;
g.connector.on_connected = on_connector_connected;
g.connector.on_failure = on_connector_failure;
connector_add_target (&g.connector, host, port);
+}
- g.polling = true;
- while (g.polling)
- poller_run (&poller);
+// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- connector_free (&g.connector);
+static XRenderColor x11_default_fg = { .alpha = 0xffff };
+static XRenderColor x11_default_bg = { 0xffff, 0xffff, 0xffff, 0xffff };
+static XErrorHandler x11_default_error_handler;
- struct str s = str_make ();
- str_pack_u32 (&s, 0);
- struct relay_command_message m = {};
- m.data.hello.command = RELAY_COMMAND_HELLO;
- m.data.hello.version = RELAY_VERSION;
- if (!relay_command_message_serialize (&m, &s))
- exit_fatal ("serialization failed");
+static struct x11_font_link *
+x11_font_link_new (XftFont *font)
+{
+ struct x11_font_link *self = xcalloc (1, sizeof *self);
+ self->font = font;
+ return self;
+}
- uint32_t len = htonl (s.len - sizeof len);
- memcpy (s.str, &len, sizeof len);
- if (errno = 0, write (g.socket, s.str, s.len) != (ssize_t) s.len)
- exit_fatal ("short send or error: %s", strerror (errno));
+static void
+x11_font_link_destroy (struct x11_font_link *self)
+{
+ XftFontClose (g.dpy, self->font);
+ free (self);
+}
- char buf[1 << 20] = "";
- while (errno = 0, read (g.socket, &len, sizeof len) == sizeof len)
+static struct x11_font_link *
+x11_font_link_open (FcPattern *pattern)
+{
+ XftFont *font = XftFontOpenPattern (g.dpy, pattern);
+ if (!font)
{
- len = ntohl (len);
- if (errno = 0, read (g.socket, buf, MIN (len, sizeof buf)) != len)
- exit_fatal ("short read or error: %s", strerror (errno));
+ FcPatternDestroy (pattern);
+ return NULL;
+ }
+ return x11_font_link_new (font);
+}
- struct msg_unpacker r = msg_unpacker_make (buf, len);
- struct relay_event_message m = {};
- if (!relay_event_message_deserialize (&m, &r))
- exit_fatal ("deserialization failed");
- if (msg_unpacker_get_available (&r))
- exit_fatal ("trailing data");
+static struct x11_font *
+x11_font_open (unsigned style)
+{
+ FcPattern *pattern = (style & X11_FONT_MONOSPACE)
+ ? FcNameParse ((const FcChar8 *) g.x11_fontname_monospace)
+ : FcNameParse ((const FcChar8 *) g.x11_fontname);
+ if (style & X11_FONT_BOLD)
+ FcPatternAdd (pattern, FC_STYLE, (FcValue) {
+ .type = FcTypeString, .u.s = (FcChar8 *) "Bold" }, FcFalse);
+ if (style & X11_FONT_ITALIC)
+ FcPatternAdd (pattern, FC_STYLE, (FcValue) {
+ .type = FcTypeString, .u.s = (FcChar8 *) "Italic" }, FcFalse);
- printf ("event: %d\n", m.data.event);
- relay_event_message_free (&m);
+ FcPattern *substituted = FcPatternDuplicate (pattern);
+ FcConfigSubstitute (NULL, substituted, FcMatchPattern);
+
+ FcResult result = 0;
+ FcPattern *match = XftFontMatch (g.dpy,
+ DefaultScreen (g.dpy), substituted, &result);
+ FcPatternDestroy (substituted);
+ struct x11_font_link *link = NULL;
+ if (!match || !(link = x11_font_link_open (match)))
+ {
+ FcPatternDestroy (pattern);
+ return NULL;
}
- exit_fatal ("short read or error: %s", strerror (errno));
+
+ struct x11_font *self = xcalloc (1, sizeof *self);
+ self->list = link;
+ self->style = style;
+ self->pattern = pattern;
+ self->unavailable = FcCharSetCreate ();
+ return self;
+}
+
+static void
+x11_font_destroy (struct x11_font *self)
+{
+ FcPatternDestroy (self->pattern);
+ FcCharSetDestroy (self->unavailable);
+ LIST_FOR_EACH (struct x11_font_link, iter, self->list)
+ x11_font_link_destroy (iter);
+ free (self);
}
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+static void
+x11_init_pixmap (void)
+{
+ int screen = DefaultScreen (g.dpy);
+ g.x11_pixmap = XCreatePixmap (g.dpy, g.x11_window,
+ MAX (1, g.ui_width), MAX (1, g.ui_height),
+ DefaultDepth (g.dpy, screen));
+
+ Visual *visual = DefaultVisual (g.dpy, screen);
+ XRenderPictFormat *format = XRenderFindVisualFormat (g.dpy, visual);
+ g.x11_pixmap_picture
+ = XRenderCreatePicture (g.dpy, g.x11_pixmap, format, 0, NULL);
+}
+
+static void
+on_x11_selection_request (XSelectionRequestEvent *ev)
+{
+ Atom xa_targets = XInternAtom (g.dpy, "TARGETS", False);
+ Atom xa_compound_text = XInternAtom (g.dpy, "COMPOUND_TEXT", False);
+ Atom xa_utf8 = XInternAtom (g.dpy, "UTF8_STRING", False);
+ Atom targets[] = { xa_targets, XA_STRING, xa_compound_text, xa_utf8 };
+
+ XEvent response = {};
+ bool ok = false;
+ Atom property = ev->property ? ev->property : ev->target;
+ if (!g.x11_selection)
+ goto out;
+
+ XICCEncodingStyle style = 0;
+ if ((ok = ev->target == xa_targets))
+ {
+ XChangeProperty (g.dpy, ev->requestor, property,
+ XA_ATOM, 32, PropModeReplace,
+ (const unsigned char *) targets, N_ELEMENTS (targets));
+ goto out;
+ }
+ else if (ev->target == XA_STRING)
+ style = XStringStyle;
+ else if (ev->target == xa_compound_text)
+ style = XCompoundTextStyle;
+ else if (ev->target == xa_utf8)
+ style = XUTF8StringStyle;
+ else
+ goto out;
+
+ // XXX: We let it crash us with BadLength, but we may, e.g., use INCR.
+ XTextProperty text = {};
+ if ((ok = !Xutf8TextListToTextProperty
+ (g.dpy, &g.x11_selection, 1, style, &text)))
+ {
+ XChangeProperty (g.dpy, ev->requestor, property,
+ text.encoding, text.format, PropModeReplace,
+ text.value, text.nitems);
+ }
+ XFree (text.value);
+
+out:
+ response.xselection.type = SelectionNotify;
+ // XXX: We should check it against the event causing XSetSelectionOwner().
+ response.xselection.time = ev->time;
+ response.xselection.requestor = ev->requestor;
+ response.xselection.selection = ev->selection;
+ response.xselection.target = ev->target;
+ response.xselection.property = ok ? property : None;
+ XSendEvent (g.dpy, ev->requestor, False, 0, &response);
+}
+
+static bool
+on_x11_input_event (XEvent *e)
+{
+ if (e->type != KeyPress)
+ return false;
+
+ // A kibibyte long buffer will have to suffice for anyone.
+ XKeyEvent *ev = &e->xkey;
+ char buf[1 << 10] = {}, *p = buf;
+ KeySym keysym = None;
+ Status status = 0;
+ int len = Xutf8LookupString
+ (g.x11_ic, ev, buf, sizeof buf, &keysym, &status);
+ if (status == XBufferOverflow)
+ print_warning ("input method overflow");
+
+ // TODO: Implement an input line.
+ switch (keysym)
+ {
+ case XK_q:
+ app_quit ();
+ return true;
+ }
+ return false;
+}
+
+static void
+on_x11_event (XEvent *ev)
+{
+ switch (ev->type)
+ {
+ case Expose:
+ {
+ XRectangle r = { ev->xexpose.x, ev->xexpose.y,
+ ev->xexpose.width, ev->xexpose.height };
+ XUnionRectWithRegion (&r, g.x11_clip, g.x11_clip);
+ poller_idle_set (&g.flip_event);
+ break;
+ }
+ case ConfigureNotify:
+ if (g.ui_width == ev->xconfigure.width
+ && g.ui_height == ev->xconfigure.height)
+ break;
+
+ g.ui_width = ev->xconfigure.width;
+ g.ui_height = ev->xconfigure.height;
+
+ XRenderFreePicture (g.dpy, g.x11_pixmap_picture);
+ XFreePixmap (g.dpy, g.x11_pixmap);
+ x11_init_pixmap ();
+ XftDrawChange (g.xft_draw, g.x11_pixmap);
+ app_invalidate ();
+ break;
+ case SelectionRequest:
+ on_x11_selection_request (&ev->xselectionrequest);
+ break;
+ case SelectionClear:
+ cstr_set (&g.x11_selection, NULL);
+ break;
+ // UnmapNotify can be received when restarting the window manager.
+ // Should this turn out to be unreliable (window not destroyed by WM
+ // upon closing), opt for the WM_DELETE_WINDOW protocol as well.
+ case DestroyNotify:
+ app_quit ();
+ break;
+ case FocusIn:
+ g.ui_focused = true;
+ app_invalidate ();
+ break;
+ case FocusOut:
+ g.ui_focused = false;
+ app_invalidate ();
+ break;
+ case KeyPress:
+ case ButtonPress:
+ case ButtonRelease:
+ case MotionNotify:
+ if (!on_x11_input_event (ev))
+ XkbBell (g.dpy, ev->xany.window, 0, None);
+ }
+}
+
+static void
+on_x11_pending (void *user_data)
+{
+ (void) user_data;
+
+ XkbEvent ev;
+ while (XPending (g.dpy))
+ {
+ if (XNextEvent (g.dpy, &ev.core))
+ exit_fatal ("XNextEvent returned non-zero");
+ if (XFilterEvent (&ev.core, None))
+ continue;
+
+ on_x11_event (&ev.core);
+ }
+
+ poller_idle_reset (&g.xpending_event);
+}
+
+static void
+on_x11_ready (const struct pollfd *pfd, void *user_data)
+{
+ (void) pfd;
+ on_x11_pending (user_data);
+}
+
+static int
+on_x11_error (Display *dpy, XErrorEvent *event)
+{
+ // Without opting for WM_DELETE_WINDOW, this window can become destroyed
+ // and hence invalid at any time. We don't use the Window much,
+ // so we should be fine ignoring these errors.
+ if ((event->error_code == BadWindow && event->resourceid == g.x11_window)
+ || (event->error_code == BadDrawable && event->resourceid == g.x11_window))
+ return app_quit (), 0;
+
+ // XXX: The simplest possible way of discarding selection management errors.
+ // XCB would be a small win here, but it is a curse at the same time.
+ if (event->error_code == BadWindow && event->resourceid != g.x11_window)
+ return 0;
+
+ return x11_default_error_handler (dpy, event);
+}
+
+// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+
+static void
+x11_init (void)
+{
+ // https://tedyin.com/posts/a-brief-intro-to-linux-input-method-framework/
+ if (!XSupportsLocale ())
+ print_warning ("locale not supported by Xlib");
+ XSetLocaleModifiers ("");
+
+ if (!(g.dpy = XkbOpenDisplay
+ (NULL, &g.xkb_base_event_code, NULL, NULL, NULL, NULL)))
+ exit_fatal ("cannot open display");
+ if (!XftDefaultHasRender (g.dpy))
+ exit_fatal ("XRender is not supported");
+ if (!(g.x11_im = XOpenIM (g.dpy, NULL, NULL, NULL)))
+ exit_fatal ("failed to open an input method");
+
+ x11_default_error_handler = XSetErrorHandler (on_x11_error);
+
+ set_cloexec (ConnectionNumber (g.dpy));
+ g.x11_event = poller_fd_make (&g.poller, ConnectionNumber (g.dpy));
+ g.x11_event.dispatcher = on_x11_ready;
+ poller_fd_set (&g.x11_event, POLLIN);
+
+ // Whenever something causes Xlib to read its socket, it can make
+ // the I/O event above fail to trigger for whatever might have ended up
+ // in its queue. So always use this instead of XSync:
+ g.xpending_event = poller_idle_make (&g.poller);
+ g.xpending_event.dispatcher = on_x11_pending;
+ poller_idle_set (&g.xpending_event);
+
+ struct xdg_xsettings settings = xdg_xsettings_make ();
+ xdg_xsettings_update (&settings, g.dpy);
+
+ if (!FcInit ())
+ print_warning ("Fontconfig initialization failed");
+ if (!(g.xft_fonts = x11_font_open (0)))
+ exit_fatal ("cannot open a font");
+
+ int screen = DefaultScreen (g.dpy);
+ Colormap cmap = DefaultColormap (g.dpy, screen);
+ XColor default_bg =
+ {
+ .red = x11_default_bg.red,
+ .green = x11_default_bg.green,
+ .blue = x11_default_bg.blue,
+ };
+ if (!XAllocColor (g.dpy, cmap, &default_bg))
+ exit_fatal ("X11 setup failed");
+
+ XSetWindowAttributes attrs =
+ {
+ .event_mask = StructureNotifyMask | ExposureMask | FocusChangeMask
+ | KeyPressMask | ButtonPressMask | ButtonReleaseMask
+ | Button1MotionMask,
+ .bit_gravity = NorthWestGravity,
+ .background_pixel = default_bg.pixel,
+ };
+
+ // Base the window's size on the regular font size.
+ // Roughly trying to match the 80x24 default dimensions of terminals.
+ g.ui_height = 24 * g.xft_fonts->list->font->height;
+ g.ui_width = g.ui_height * 4 / 3;
+
+ long im_event_mask = 0;
+ if (!XGetIMValues (g.x11_im, XNFilterEvents, &im_event_mask, NULL))
+ attrs.event_mask |= im_event_mask;
+
+ Visual *visual = DefaultVisual (g.dpy, screen);
+ g.x11_window = XCreateWindow (g.dpy, RootWindow (g.dpy, screen), 100, 100,
+ g.ui_width, g.ui_height, 0, CopyFromParent, InputOutput, visual,
+ CWEventMask | CWBackPixel | CWBitGravity, &attrs);
+ g.x11_clip = XCreateRegion ();
+
+ XTextProperty prop = {};
+ char *name = PROGRAM_NAME;
+ if (!Xutf8TextListToTextProperty (g.dpy, &name, 1, XUTF8StringStyle, &prop))
+ XSetWMName (g.dpy, g.x11_window, &prop);
+ XFree (prop.value);
+
+ // This is a rather GNOME-centric mechanism, but it's better than nothing.
+ const char *icon_theme_name = NULL;
+ const struct xdg_xsettings_setting *setting =
+ str_map_find (&settings.settings, "Net/IconThemeName");
+ if (setting != NULL && setting->type == XDG_XSETTINGS_STRING)
+ icon_theme_name = setting->string.str;
+ icon_theme_set_window_icon (g.dpy, g.x11_window, icon_theme_name, name);
+ xdg_xsettings_free (&settings);
+
+ // TODO: It is possible to do, e.g., on-the-spot.
+ XIMStyle im_style = XIMPreeditNothing | XIMStatusNothing;
+ XIMStyles *im_styles = NULL;
+ bool im_style_found = false;
+ if (!XGetIMValues (g.x11_im, XNQueryInputStyle, &im_styles, NULL)
+ && im_styles)
+ {
+ for (unsigned i = 0; i < im_styles->count_styles; i++)
+ im_style_found |= im_styles->supported_styles[i] == im_style;
+ XFree (im_styles);
+ }
+ if (!im_style_found)
+ print_warning ("failed to find the desired input method style");
+ if (!(g.x11_ic = XCreateIC (g.x11_im,
+ XNInputStyle, im_style,
+ XNClientWindow, g.x11_window,
+ NULL)))
+ exit_fatal ("failed to open an input context");
+
+ XSetICFocus (g.x11_ic);
+
+ x11_init_pixmap ();
+ g.xft_draw = XftDrawCreate (g.dpy, g.x11_pixmap, visual, cmap);
+
+ XMapWindow (g.dpy, g.x11_window);
+}
+
+// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+
+static bool
+render_line (int *bottom, const char *text)
+{
+ XftFont *font = g.xft_fonts->list->font;
+ XftColor color = { .color = x11_default_fg };
+ XftDrawStringUtf8 (g.xft_draw, &color, font, 0, *bottom - font->descent,
+ (const FcChar8 *) text, strlen (text));
+ return (*bottom -= font->height) > 0;
+}
+
+static void
+x11_render (void)
+{
+ XRenderFillRectangle (g.dpy, PictOpSrc, g.x11_pixmap_picture,
+ &x11_default_bg, 0, 0, g.ui_width, g.ui_height);
+
+ int bottom = g.ui_height;
+ render_line (&bottom, "xF");
+ if (!g.buffer_current)
+ render_line (&bottom, "-");
+ else
+ {
+ struct buffer *buffer = g.buffer_current;
+ render_line (&bottom, buffer->buffer_name);
+ struct buffer_line *line = buffer->lines_tail;
+ for (; line; line = line->prev)
+ if (!render_line (&bottom, line->text))
+ break;
+ }
+
+ XRectangle r = { 0, 0, g.ui_width, g.ui_height };
+ XUnionRectWithRegion (&r, g.x11_clip, g.x11_clip);
+ poller_idle_set (&g.xpending_event);
+}
+
+static void
+x11_flip (void)
+{
+ // This exercise in futility doesn't seem to affect CPU usage much.
+ XRectangle r = {};
+ XClipBox (g.x11_clip, &r);
+ XCopyArea (g.dpy, g.x11_pixmap, g.x11_window,
+ DefaultGC (g.dpy, DefaultScreen (g.dpy)),
+ r.x, r.y, r.width, r.height, r.x, r.y);
+
+ XSubtractRegion (g.x11_clip, g.x11_clip, g.x11_clip);
+ poller_idle_set (&g.xpending_event);
+}
+
+// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+
+static void
+app_on_flip (void *user_data)
+{
+ (void) user_data;
+ poller_idle_reset (&g.flip_event);
+
+ // Waste of time, and may cause X11 to render uninitialised pixmaps.
+ if (g.polling && !g.refresh_event.active)
+ x11_flip ();
+}
+
+static void
+app_on_refresh (void *user_data)
+{
+ (void) user_data;
+ poller_idle_reset (&g.refresh_event);
+
+ x11_render ();
+ poller_idle_set (&g.flip_event);
+}
+
+// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+
+static void
+app_init_poller_events (void)
+{
+ g.refresh_event = poller_idle_make (&g.poller);
+ g.refresh_event.dispatcher = app_on_refresh;
+
+ g.flip_event = poller_idle_make (&g.poller);
+ g.flip_event.dispatcher = app_on_flip;
+}
+
int
main (int argc, char *argv[])
{
@@ -166,7 +1162,15 @@ main (int argc, char *argv[])
if (!port)
exit_fatal ("missing port number/service name");
- // TODO: Actually implement an X11-based user interface.
- protocol_test (host, port);
+ app_init_context ();
+ app_init_poller_events ();
+ x11_init ();
+
+ relay_connect (host, port);
+ free (address);
+
+ g.polling = true;
+ while (g.polling)
+ poller_run (&g.poller);
return 0;
}
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/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) {