aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--CMakeLists.txt5
-rw-r--r--LICENSE2
-rw-r--r--NEWS27
-rw-r--r--README.adoc25
m---------liberty0
-rwxr-xr-xtest52
-rw-r--r--test.lua72
-rw-r--r--xA/.gitignore5
-rw-r--r--xA/Makefile36
-rw-r--r--xA/go.mod46
-rw-r--r--xA/go.sum84
-rw-r--r--xA/xA-highlighted.svg23
-rw-r--r--xA/xA.go1651
-rw-r--r--xA/xA.svg23
-rw-r--r--xC.c840
-rw-r--r--xC.lxdr53
-rw-r--r--xK-version2
-rw-r--r--xM/gen-icon.swift3
-rw-r--r--xM/main.swift10
-rw-r--r--xN/xN.go10
-rw-r--r--xP/go.mod9
-rw-r--r--xP/go.sum64
-rw-r--r--xP/public/xP.js46
-rw-r--r--xP/xP.go9
-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--xS/Makefile2
-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
41 files changed, 4982 insertions, 663 deletions
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 9afcdf4..8492a00 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -212,6 +212,11 @@ if (BUILD_TESTING)
add_test (NAME custom-static-analysis
COMMAND ${PROJECT_SOURCE_DIR}/test-static)
endif ()
+option (BUILD_TESTING_WDYE "Build the integration test" OFF)
+if (BUILD_TESTING_WDYE)
+ add_subdirectory (liberty/tools/wdye)
+ add_test (NAME integration COMMAND wdye "${PROJECT_SOURCE_DIR}/test.lua")
+endif ()
# Various clang-based diagnostics, loads of fake positives and spam
file (GLOB clang_tidy_sources *.c)
diff --git a/LICENSE b/LICENSE
index d58be36..69c9c4c 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2014 - 2024, Přemysl Eric Janouch <p@janouch.name>
+Copyright (c) 2014 - 2025, Přemysl Eric Janouch <p@janouch.name>
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted.
diff --git a/NEWS b/NEWS
index 42b71a5..d9699ca 100644
--- a/NEWS
+++ b/NEWS
@@ -1,3 +1,30 @@
+Unreleased
+
+ * xC: added more characters as nickname delimiters,
+ so that @nick works as a highlight
+
+ * xC: prevented rare crashes in relay code
+
+ * xP: added a network lag indicator to the user interface
+
+ * Bumped relay protocol version
+
+
+2.1.0 (2024-12-19) "Bunnyrific"
+
+ * xC: fixed a crash when the channel topic had too many formatting items
+
+ * xC: fixed keyboard EOF behaviour with Readline >= 8.0
+
+ * xC: made it possible to stream commands into the binary
+
+ * xM/xW: various bugfixes
+
+ * Added a Fyne frontend for xC called xA
+
+ * Added a Qt Widgets frontend for xC called xT
+
+
2.0.0 (2024-07-28) "Perfect Is the Enemy of Good"
* xD: now using SHA-256 for client certificate fingerprints
diff --git a/README.adoc b/README.adoc
index 3db0db4..1866b2a 100644
--- a/README.adoc
+++ b/README.adoc
@@ -2,9 +2,9 @@ xK
==
'xK' (chat kit) is an IRC software suite consisting of a daemon, bot, notifier,
-terminal client, and web/Windows/macOS frontends for the client. It's all
-you're ever going to need for chatting, so long as you can make do with slightly
-minimalist software.
+terminal client, and web/Windows/macOS/Linux/FreeBSD/Android/iOS frontends
+for the client. It's all you're ever going to need for chatting, so long as
+you can make do with slightly minimalist software.
They're all lean on dependencies, and offer a maximally permissive licence.
@@ -33,9 +33,10 @@ including link:xC.adoc#_key_bindings[keyboard shortcuts].
image::xP.webp[align="center"]
-xF
---
-The X11 frontend for 'xC', still under development.
+xA, xT, xF, xW, xM
+------------------
+Fyne, Qt Widgets, X11, Win32, Cocoa frontends for 'xC'.
+Using them is not recommended.
xD
--
@@ -147,6 +148,18 @@ For remote use, it's recommended to put 'xP' behind a reverse proxy, with TLS,
and some form of HTTP authentication. Pass the external URL of the WebSocket
endpoint as the third command line argument in this case.
+xA
+~~
+The Fyne frontend supports all of Linux, FreeBSD, Windows, macOS, Android, and
+iOS natively, albeit somewhat poorly. Only use `fyne` or `fyne-cross` after
+running `make generate` first.
+
+xT
+~~
+The Qt Widgets frontend is a separate CMake subproject. It generally supports
+all desktop operating systems. To avoid having to specify the relay address
+each time you run it, pass it on the command line.
+
xW
~~
The Win32 frontend is a separate CMake subproject that should be compiled
diff --git a/liberty b/liberty
-Subproject 75fc6f1c374796f9e794297c3893089009b8772
+Subproject 31ae40085206dc365a15fd6e9d13978e392f8b3
diff --git a/test b/test
deleted file mode 100755
index e8c2b53..0000000
--- a/test
+++ /dev/null
@@ -1,52 +0,0 @@
-#!/usr/bin/expect -f
-# Very basic end-to-end testing for CI
-set tempdir [exec mktemp -d]
-set ::env(XDG_CONFIG_HOME) $tempdir
-
-# Run the daemon to test against
-system ./xD --write-default-cfg
-spawn ./xD -d
-
-# 10 seconds is a bit too much
-set timeout 5
-
-spawn ./xC
-
-# Fuck this Tcl shit, I want the exit code
-expect_after {
- eof {
- puts ""
- puts "Child exited prematurely"
- exit 1
- }
-}
-
-# Connect to the daemon
-send "/server add localhost\n"
-expect "]"
-send "/set servers.localhost.addresses = \"localhost\"\n"
-expect "Option changed"
-send "/disconnect\n"
-expect "]"
-send "/connect\n"
-expect "Welcome to"
-
-# Try some chatting
-send "/join #test\n"
-expect "has joined"
-send "Hello\n"
-expect "Hello"
-
-# Attributes
-send "\x1bmbBold text! \x1bmc0,5And colors.\n"
-expect "]"
-
-# Try basic commands
-send "/set\n"
-expect "]"
-send "/help\n"
-expect "]"
-
-# Quit
-send "/quit\n"
-expect "Shutting down"
diff --git a/test.lua b/test.lua
new file mode 100644
index 0000000..2edeca8
--- /dev/null
+++ b/test.lua
@@ -0,0 +1,72 @@
+#!/usr/bin/env wdye
+-- Very basic end-to-end testing for CI
+function exec (...)
+ local p = wdye.spawn(...)
+ local out = wdye.expect(p:eof {function (p) return p[0] end})
+ if not out then
+ error "exec() timeout"
+ end
+
+ local status = p:wait()
+ if status ~= 0 then
+ io.write(out, "\n")
+ error("exit status " .. status)
+ end
+ return out:gsub("%s+$", "")
+end
+
+local temp = exec {"mktemp", "-d"}
+local atexit = {}
+setmetatable(atexit, {__gc = function () exec {"rm", "-rf", "--", temp} end})
+
+local env = {XDG_CONFIG_HOME=temp, TERM="xterm"}
+exec {"./xD", "--write-default-cfg", environ=env}
+
+-- Run the daemon to test against (assuming the default port 6667)
+local xD = wdye.spawn {"./xD", "-d", environ=env}
+local xC = wdye.spawn {"./xC", environ=env}
+
+function send (...) xC:send(...) end
+function expect (string)
+ wdye.expect(xC:exact {string},
+ wdye.timeout {5, function (p) error "xC timeout" end},
+ xC:eof {function (p) error "xC exited prematurely" end})
+end
+
+-- Connect to the daemon
+send "/server add localhost\n"
+expect "]"
+send "/set servers.localhost.addresses = \"localhost\"\n"
+expect "Option changed"
+send "/disconnect\n"
+expect "]"
+send "/connect\n"
+expect "Welcome to"
+
+-- Try some chatting
+send "/join #test\n"
+expect "has joined"
+send "Hello\n"
+expect "Hello"
+
+-- Attributes
+send "\x1bmbBold text! \x1bmc0,5And colors.\n"
+expect "]"
+
+-- Try basic commands
+send "/set\n"
+expect "]"
+send "/help\n"
+expect "]"
+
+-- Quit
+send "/quit\n"
+expect "Shutting down"
+
+local s1 = xC:wait()
+assert(s1 == 0, "xC exited abnormally: " .. s1)
+
+-- Send SIGINT (^C)
+xD:send "\003"
+local s2 = xD:wait()
+assert(s2 == 0, "xD exited abnormally: " .. s2)
diff --git a/xA/.gitignore b/xA/.gitignore
new file mode 100644
index 0000000..5e6a147
--- /dev/null
+++ b/xA/.gitignore
@@ -0,0 +1,5 @@
+/xA
+/proto.go
+/FyneApp.toml
+/*.png
+/beep.raw
diff --git a/xA/Makefile b/xA/Makefile
new file mode 100644
index 0000000..d0f0449
--- /dev/null
+++ b/xA/Makefile
@@ -0,0 +1,36 @@
+.POSIX:
+.SUFFIXES:
+.SUFFIXES: .png .svg
+AWK = env LC_ALL=C awk
+
+tools = ../liberty/tools
+generated = FyneApp.toml xA.png xA-highlighted.png beep.raw proto.go
+outputs = xA $(generated)
+all: $(outputs)
+generate: $(generated)
+
+FyneApp.toml: ../xK-version
+ printf "\
+ [Details]\n\
+ Icon = 'xA.png'\n\
+ Name = 'xA'\n\
+ ID = 'name.janouch.xA'\n\
+ Version = '$$(cat ../xK-version)'\n\
+ Build = 1\n\
+ \n\
+ [LinuxAndBSD]\n\
+ GenericName = 'IRC Client'\n\
+ Categories = ['Network', 'Chat', 'IRCClient']\n" > $@
+.svg.png:
+ rsvg-convert --output=$@ -- $<
+beep.raw:
+ sox -Dr 44100 -c 1 -e signed-integer -b 16 -L -n $@ \
+ synth 0.1 0 25 triangle 800 vol 0.5 fade t 0 -0 0.005 pad 0 0.05
+proto.go: $(tools)/lxdrgen.awk $(tools)/lxdrgen-go.awk ../xC.lxdr
+ $(AWK) -f $(tools)/lxdrgen.awk -f $(tools)/lxdrgen-go.awk \
+ -v PrefixCamel=Relay ../xC.lxdr > $@
+xA: xA.go ../xK-version $(generated)
+ go build -ldflags "-X 'main.projectVersion=$$(cat ../xK-version)'" -o $@ \
+ -gcflags=all="-N -l"
+clean:
+ rm -f $(outputs)
diff --git a/xA/go.mod b/xA/go.mod
new file mode 100644
index 0000000..49c25cc
--- /dev/null
+++ b/xA/go.mod
@@ -0,0 +1,46 @@
+module janouch.name/xK/xA
+
+go 1.23.0
+
+toolchain go1.24.0
+
+require (
+ fyne.io/fyne/v2 v2.6.0
+ github.com/ebitengine/oto/v3 v3.3.3
+)
+
+require (
+ fyne.io/systray v1.11.0 // indirect
+ github.com/BurntSushi/toml v1.5.0 // indirect
+ github.com/davecgh/go-spew v1.1.1 // indirect
+ github.com/ebitengine/purego v0.8.2 // indirect
+ github.com/fredbi/uri v1.1.0 // indirect
+ github.com/fsnotify/fsnotify v1.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-20250301202403-da16c1255728 // indirect
+ github.com/go-text/render v0.2.0 // indirect
+ github.com/go-text/typesetting v0.3.0 // indirect
+ github.com/godbus/dbus/v5 v5.1.0 // 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/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.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.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
new file mode 100644
index 0000000..3a6f7ce
--- /dev/null
+++ b/xA/go.sum
@@ -0,0 +1,84 @@
+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 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.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.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/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.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/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/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/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/pkg/profile v1.7.0 h1:hnbDkaNWPCLMO9wGLdBFTIZvzDrDfBM2072E1S9gJkA=
+github.com/pkg/profile v1.7.0/go.mod h1:8Uer0jas47ZQMJ7VD+OHknK4YDY07LPUC6dEvqDjvNo=
+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/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/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-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
+gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/xA/xA-highlighted.svg b/xA/xA-highlighted.svg
new file mode 100644
index 0000000..c1345ee
--- /dev/null
+++ b/xA/xA-highlighted.svg
@@ -0,0 +1,23 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg version="1.1" width="512" height="512" viewBox="0 0 512 512"
+ xmlns="http://www.w3.org/2000/svg">
+
+ <defs>
+ <linearGradient id="background" x1="0" y1="0" x2="0" y2="1">
+ <stop stop-color="#ffaa00" offset="0" />
+ <stop stop-color="#ffffff" offset="1" />
+ </linearGradient>
+ <clipPath id="clip">
+ <rect x="0" y="0" width="1" height="1" />
+ </clipPath>
+ </defs>
+
+ <rect x="0" y="0" width="512" height="512" fill="url(#background)" />
+
+ <g transform="translate(64, 64) scale(384)" stroke-linecap="square">
+ <g clip-path="url(#clip)">
+ <path stroke="#ff0000" stroke-width="0.125"
+ d="M 0.25,1.1 0.65,-0.1 M 0.75,1.1 0.35,-0.1 M 0.225,0.75 0.775,0.75" />
+ </g>
+ </g>
+</svg>
diff --git a/xA/xA.go b/xA/xA.go
new file mode 100644
index 0000000..b4f796c
--- /dev/null
+++ b/xA/xA.go
@@ -0,0 +1,1651 @@
+// Copyright (c) 2024 - 2025, Přemysl Eric Janouch <p@janouch.name>
+// SPDX-License-Identifier: 0BSD
+
+package main
+
+import (
+ "bufio"
+ "bytes"
+ "context"
+ _ "embed"
+ "encoding/binary"
+ "errors"
+ "flag"
+ "fmt"
+ "image/color"
+ "io"
+ "log"
+ "net"
+ "net/url"
+ "os"
+ "regexp"
+ "slices"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/ebitengine/oto/v3"
+
+ "fyne.io/fyne/v2"
+ "fyne.io/fyne/v2/app"
+ "fyne.io/fyne/v2/container"
+ "fyne.io/fyne/v2/dialog"
+ "fyne.io/fyne/v2/driver/desktop"
+ "fyne.io/fyne/v2/driver/mobile"
+ "fyne.io/fyne/v2/theme"
+ "fyne.io/fyne/v2/widget"
+)
+
+var (
+ debug = flag.Bool("debug", false, "enable debug output")
+ projectName = "xA"
+ projectVersion = "?"
+
+ //go:embed xA.png
+ iconNormal []byte
+ //go:embed xA-highlighted.png
+ iconHighlighted []byte
+ //go:embed beep.raw
+ beepSample []byte
+
+ resourceIconNormal = fyne.NewStaticResource(
+ "xA.png", iconNormal)
+ resourceIconHighlighted = fyne.NewStaticResource(
+ "xA-highlighted.png", iconHighlighted)
+)
+
+// --- Theme -------------------------------------------------------------------
+
+type customTheme struct{}
+
+const (
+ colorNameRenditionError fyne.ThemeColorName = "renditionError"
+ colorNameRenditionJoin fyne.ThemeColorName = "renditionJoin"
+ colorNameRenditionPart fyne.ThemeColorName = "renditionPart"
+ colorNameRenditionAction fyne.ThemeColorName = "renditionAction"
+
+ colorNameBufferTimestamp fyne.ThemeColorName = "bufferTimestamp"
+ colorNameBufferLeaked fyne.ThemeColorName = "bufferLeaked"
+)
+
+func convertColor(c int) color.Color {
+ base16 := []uint16{
+ 0x000, 0x800, 0x080, 0x880, 0x008, 0x808, 0x088, 0xccc,
+ 0x888, 0xf00, 0x0f0, 0xff0, 0x00f, 0xf0f, 0x0ff, 0xfff,
+ }
+ if c < 16 {
+ r := 0xf & uint8(base16[c]>>8)
+ g := 0xf & uint8(base16[c]>>4)
+ b := 0xf & uint8(base16[c])
+ return color.RGBA{r * 0x11, g * 0x11, b * 0x11, 0xff}
+ }
+ if c >= 216 {
+ return color.Gray{8 + uint8(c-216)*10}
+ }
+
+ var (
+ i = uint8(c - 16)
+ r = i / 36 >> 0
+ g = (i / 6 >> 0) % 6
+ b = i % 6
+ )
+ if r != 0 {
+ r = 55 + 40*r
+ }
+ if g != 0 {
+ g = 55 + 40*g
+ }
+ if b != 0 {
+ b = 55 + 40*b
+ }
+ return color.RGBA{r, g, b, 0xff}
+}
+
+var ircColors = make(map[fyne.ThemeColorName]color.Color)
+
+func ircColorName(color int) fyne.ThemeColorName {
+ return fyne.ThemeColorName(fmt.Sprintf("irc%02x", color))
+}
+
+func init() {
+ for color := 0; color < 256; color++ {
+ ircColors[ircColorName(color)] = convertColor(color)
+ }
+}
+
+func (t *customTheme) Color(
+ name fyne.ThemeColorName, variant fyne.ThemeVariant) color.Color {
+ /*
+ // Fyne may use a dark background with the Light variant,
+ // which makes the UI unusable.
+ if runtime.GOOS == "android" {
+ 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 &&
+ variant == theme.VariantLight {
+ return color.Black
+ }
+
+ switch name {
+ case colorNameRenditionError:
+ return color.RGBA{0xff, 0x00, 0x00, 0xff}
+ case colorNameRenditionJoin:
+ return color.RGBA{0x00, 0x88, 0x00, 0xff}
+ case colorNameRenditionPart:
+ return color.RGBA{0x88, 0x00, 0x00, 0xff}
+ case colorNameRenditionAction:
+ return color.RGBA{0x88, 0x00, 0x00, 0xff}
+
+ case colorNameBufferTimestamp, colorNameBufferLeaked:
+ return color.RGBA{0x88, 0x88, 0x88, 0xff}
+ }
+
+ if c, ok := ircColors[name]; ok {
+ return c
+ }
+ return theme.DefaultTheme().Color(name, variant)
+}
+
+func (t *customTheme) Font(style fyne.TextStyle) fyne.Resource {
+ return theme.DefaultTheme().Font(style)
+}
+
+func (t *customTheme) Icon(i fyne.ThemeIconName) fyne.Resource {
+ return theme.DefaultTheme().Icon(i)
+}
+
+func (t *customTheme) Size(s fyne.ThemeSizeName) float32 {
+ switch s {
+ case theme.SizeNameInnerPadding:
+ return 2
+ default:
+ return theme.DefaultTheme().Size(s)
+ }
+}
+
+// --- Relay state -------------------------------------------------------------
+
+type server struct {
+ state RelayServerState
+ user string
+ userModes string
+}
+
+type bufferLineItem struct {
+ format fyne.TextStyle
+ // For RichTextStyle.ColorName.
+ color fyne.ThemeColorName
+ // XXX: Fyne's RichText doesn't support background colours.
+ background fyne.ThemeColorName
+ text string
+ link *url.URL
+}
+
+type bufferLine struct {
+ /// Leaked from another buffer, but temporarily staying in another one.
+ leaked bool
+
+ isUnimportant bool
+ isHighlight bool
+ rendition RelayRendition
+ when time.Time
+ items []bufferLineItem
+}
+
+type buffer struct {
+ bufferName string
+ hideUnimportant bool
+ kind RelayBufferKind
+ serverName string
+ lines []bufferLine
+
+ // Channel:
+
+ topic []bufferLineItem
+ modes string
+
+ // Stats:
+
+ newMessages int
+ newUnimportantMessages int
+ highlighted bool
+
+ // Input:
+
+ input string
+ inputRow, inputColumn int
+ history []string
+ historyAt int
+}
+
+type callback func(err string, response *RelayResponseData)
+
+const (
+ preferenceAddress = "address"
+)
+
+var (
+ backendAddress string
+ backendContext context.Context
+ backendCancel context.CancelFunc
+ backendConn net.Conn
+
+ backendLock sync.Mutex
+
+ // Connection state:
+
+ commandSeq uint32
+ commandCallbacks = make(map[uint32]callback)
+
+ buffers []buffer
+ bufferCurrent string
+ bufferLast string
+
+ servers = make(map[string]*server)
+
+ // Sound:
+
+ otoContext *oto.Context
+ otoReady chan struct{}
+
+ // Widgets:
+
+ inForeground = true
+
+ wConnect *dialog.FormDialog
+
+ wWindow fyne.Window
+ wTopic *widget.RichText
+ wBufferList *widget.List
+ wRichText *widget.RichText
+ wRichScroll *container.Scroll
+ wLog *logEntry
+ wPrompt *widget.Label
+ wDown *widget.Icon
+ wStatus *widget.Label
+ wEntry *inputEntry
+)
+
+// -----------------------------------------------------------------------------
+
+func showErrorMessage(text string) {
+ dialog.ShowError(errors.New(text), wWindow)
+}
+
+func beep() {
+ if otoContext == nil {
+ return
+ }
+ go func() {
+ <-otoReady
+ p := otoContext.NewPlayer(bytes.NewReader(beepSample))
+ p.Play()
+ for p.IsPlaying() {
+ time.Sleep(time.Second)
+ }
+ }()
+}
+
+// --- Networking --------------------------------------------------------------
+
+func relayReadMessage(r io.Reader) (m RelayEventMessage, ok bool) {
+ var length uint32
+ if err := binary.Read(r, binary.BigEndian, &length); err != nil {
+ log.Println("Event receive failed: " + err.Error())
+ return
+ }
+ b := make([]byte, length)
+ if _, err := io.ReadFull(r, b); err != nil {
+ log.Println("Event receive failed: " + err.Error())
+ return
+ }
+
+ if after, ok2 := m.ConsumeFrom(b); !ok2 {
+ log.Println("Event deserialization failed")
+ return
+ } else if len(after) != 0 {
+ log.Println("Event deserialization failed: trailing data")
+ return
+ }
+
+ if *debug {
+ log.Printf("<? %v\n", b)
+
+ j, err := m.MarshalJSON()
+ if err != nil {
+ log.Println("Event marshalling failed: " + err.Error())
+ return
+ }
+
+ log.Printf("<- %s\n", j)
+ }
+ return m, true
+}
+
+func relaySend(data RelayCommandData, callback callback) bool {
+ backendLock.Lock()
+ defer backendLock.Unlock()
+
+ m := RelayCommandMessage{
+ CommandSeq: commandSeq,
+ Data: data,
+ }
+ if callback == nil {
+ callback = func(err string, response *RelayResponseData) {
+ if response == nil {
+ showErrorMessage(err)
+ }
+ }
+ }
+ commandCallbacks[m.CommandSeq] = callback
+ commandSeq++
+
+ // TODO(p): Handle errors better.
+ b, ok := m.AppendTo(make([]byte, 4))
+ if !ok {
+ log.Println("Command serialization failed")
+ return false
+ }
+ binary.BigEndian.PutUint32(b[:4], uint32(len(b)-4))
+ if _, err := backendConn.Write(b); err != nil {
+ log.Println("Command send failed: " + err.Error())
+ return false
+ }
+
+ if *debug {
+ log.Printf("-> %v\n", b)
+ }
+ return true
+}
+
+// --- Buffers -----------------------------------------------------------------
+
+func bufferByName(name string) *buffer {
+ for i := range buffers {
+ if buffers[i].bufferName == name {
+ return &buffers[i]
+ }
+ }
+ return nil
+}
+
+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()
+}
+
+func bufferPushLine(b *buffer, line bufferLine) {
+ b.lines = append(b.lines, line)
+
+ // Fyne's text layouting is extremely slow.
+ // The limit could be made configurable,
+ // and we could use a ring buffer approach to storing the lines.
+ if len(b.lines) > 100 {
+ b.lines = slices.Delete(b.lines, 0, 1)
+ }
+}
+
+// --- UI state refresh --------------------------------------------------------
+
+func refreshIcon() {
+ resource := resourceIconNormal
+ for _, b := range buffers {
+ if b.highlighted {
+ resource = resourceIconHighlighted
+ break
+ }
+ }
+ wWindow.SetIcon(resource)
+}
+
+func refreshTopic(topic []bufferLineItem) {
+ wTopic.Segments = nil
+ for _, item := range topic {
+ if item.link != nil {
+ wTopic.Segments = append(wTopic.Segments,
+ &widget.HyperlinkSegment{Text: item.text, URL: item.link})
+ continue
+ }
+ wTopic.Segments = append(wTopic.Segments, &widget.TextSegment{
+ Text: item.text,
+ Style: widget.RichTextStyle{
+ Alignment: fyne.TextAlignLeading,
+ ColorName: item.color,
+ Inline: true,
+ SizeName: theme.SizeNameText,
+ TextStyle: item.format,
+ },
+ })
+ }
+ wTopic.Refresh()
+}
+
+func refreshBufferList() {
+ // This seems to be enough, even for removals.
+ for i := range buffers {
+ wBufferList.RefreshItem(widget.ListItemID(i))
+ }
+}
+
+func refreshPrompt() {
+ var prompt string
+ if b := bufferByName(bufferCurrent); b == nil {
+ prompt = "Synchronizing..."
+ } else if server, ok := servers[b.serverName]; ok {
+ prompt = server.user
+ if server.userModes != "" {
+ prompt += "(" + server.userModes + ")"
+ }
+ if prompt == "" {
+ prompt = "(" + server.state.String() + ")"
+ }
+ }
+ wPrompt.SetText(prompt)
+}
+
+func refreshStatus() {
+ if bufferAtBottom() {
+ wDown.Hide()
+ } else {
+ wDown.Show()
+ }
+
+ status := bufferCurrent
+ if b := bufferByName(bufferCurrent); b != nil {
+ if b.modes != "" {
+ status += "(+" + b.modes + ")"
+ }
+ if b.hideUnimportant {
+ status += "<H>"
+ }
+ }
+
+ 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{} }
+
+func convertItemFormatting(
+ item RelayItemData, cf *bufferLineItem, inverse *bool) {
+ switch data := item.Variant.(type) {
+ case *RelayItemDataReset:
+ *cf = defaultBufferLineItem()
+ case *RelayItemDataFlipBold:
+ cf.format.Bold = !cf.format.Bold
+ case *RelayItemDataFlipItalic:
+ cf.format.Italic = !cf.format.Italic
+ case *RelayItemDataFlipUnderline:
+ cf.format.Underline = !cf.format.Underline
+ case *RelayItemDataFlipCrossedOut:
+ // https://github.com/fyne-io/fyne/issues/1084
+ case *RelayItemDataFlipInverse:
+ *inverse = !*inverse
+ case *RelayItemDataFlipMonospace:
+ cf.format.Monospace = !cf.format.Monospace
+ case *RelayItemDataFgColor:
+ if data.Color < 0 {
+ cf.color = ""
+ } else {
+ cf.color = ircColorName(int(data.Color))
+ }
+ case *RelayItemDataBgColor:
+ if data.Color < 0 {
+ cf.background = ""
+ } else {
+ cf.background = ircColorName(int(data.Color))
+ }
+ }
+}
+
+var linkRE = regexp.MustCompile(`https?://` +
+ `(?:[^\[\](){}<>"'\s]|\([^\[\](){}<>"'\s]*\))+` +
+ `(?:[^\[\](){}<>"'\s,.:]|\([^\[\](){}<>"'\s]*\))`)
+
+func convertLinks(
+ item bufferLineItem, items []bufferLineItem) []bufferLineItem {
+ end, matches := 0, linkRE.FindAllStringIndex(item.text, -1)
+ for _, m := range matches {
+ url, _ := url.Parse(item.text[m[0]:m[1]])
+ if url == nil {
+ continue
+ }
+ if end < m[0] {
+ subitem := item
+ subitem.text = item.text[end:m[0]]
+ items = append(items, subitem)
+ }
+
+ subitem := item
+ subitem.text = item.text[m[0]:m[1]]
+ subitem.link = url
+ items = append(items, subitem)
+
+ end = m[1]
+ }
+ if end < len(item.text) {
+ subitem := item
+ subitem.text = item.text[end:]
+ items = append(items, subitem)
+ }
+ return items
+}
+
+func convertItems(items []RelayItemData) []bufferLineItem {
+ result := []bufferLineItem{}
+ cf, inverse := defaultBufferLineItem(), false
+ for _, it := range items {
+ text, ok := it.Variant.(*RelayItemDataText)
+ if !ok {
+ convertItemFormatting(it, &cf, &inverse)
+ continue
+ }
+
+ item := cf
+ item.text = text.Text
+ if inverse {
+ item.color, item.background = item.background, item.color
+ }
+ result = convertLinks(item, result)
+ }
+ return result
+}
+
+// --- Buffer output -----------------------------------------------------------
+
+func convertBufferLine(m *RelayEventDataBufferLine) bufferLine {
+ return bufferLine{
+ items: convertItems(m.Items),
+ isUnimportant: m.IsUnimportant,
+ isHighlight: m.IsHighlight,
+ rendition: m.Rendition,
+ when: time.UnixMilli(int64(m.When)),
+ }
+}
+
+func bufferPrintDateChange(last, current time.Time) {
+ last, current = last.Local(), current.Local()
+ if last.Year() == current.Year() &&
+ last.Month() == current.Month() &&
+ last.Day() == current.Day() {
+ return
+ }
+
+ wRichText.Segments = append(wRichText.Segments, &widget.TextSegment{
+ Style: widget.RichTextStyle{
+ Alignment: fyne.TextAlignLeading,
+ ColorName: "",
+ Inline: false,
+ SizeName: theme.SizeNameText,
+ TextStyle: fyne.TextStyle{Bold: true},
+ },
+ Text: current.Format(time.DateOnly),
+ })
+}
+
+func bufferPrintAndWatchTrailingDateChanges() {
+ current := time.Now()
+ b := bufferByName(bufferCurrent)
+ if b != nil && len(b.lines) != 0 {
+ last := b.lines[len(b.lines)-1].when
+ bufferPrintDateChange(last, current)
+ }
+
+ // TODO(p): The watching part.
+}
+
+func bufferPrintLine(lines []bufferLine, index int) {
+ line := &lines[index]
+
+ last, current := time.Time{}, line.when
+ if index == 0 {
+ last = time.Now()
+ } else {
+ last = lines[index-1].when
+ }
+
+ bufferPrintDateChange(last, current)
+
+ texts := []widget.RichTextSegment{&widget.TextSegment{
+ Text: line.when.Format("15:04:05 "),
+ Style: widget.RichTextStyle{
+ Alignment: fyne.TextAlignLeading,
+ ColorName: colorNameBufferTimestamp,
+ Inline: true,
+ SizeName: theme.SizeNameText,
+ TextStyle: fyne.TextStyle{},
+ }}}
+
+ // Tabstops won't quite help us here, since we need it centred.
+ prefix := ""
+ pcf := widget.RichTextStyle{
+ Alignment: fyne.TextAlignLeading,
+ Inline: true,
+ SizeName: theme.SizeNameText,
+ TextStyle: fyne.TextStyle{Monospace: true},
+ }
+ switch line.rendition {
+ case RelayRenditionBare:
+ case RelayRenditionIndent:
+ prefix = " "
+ case RelayRenditionStatus:
+ prefix = " - "
+ case RelayRenditionError:
+ prefix = "=!= "
+ pcf.ColorName = colorNameRenditionError
+ case RelayRenditionJoin:
+ prefix = "--> "
+ pcf.ColorName = colorNameRenditionJoin
+ case RelayRenditionPart:
+ prefix = "<-- "
+ pcf.ColorName = colorNameRenditionPart
+ case RelayRenditionAction:
+ prefix = " * "
+ pcf.ColorName = colorNameRenditionAction
+ }
+
+ if prefix != "" {
+ style := pcf
+ if line.leaked {
+ style.ColorName = colorNameBufferLeaked
+ }
+ texts = append(texts, &widget.TextSegment{
+ Text: prefix,
+ Style: style,
+ })
+ }
+ for _, item := range line.items {
+ if item.link != nil {
+ texts = append(texts,
+ &widget.HyperlinkSegment{Text: item.text, URL: item.link})
+ continue
+ }
+ style := widget.RichTextStyle{
+ Alignment: fyne.TextAlignLeading,
+ ColorName: item.color,
+ Inline: true,
+ SizeName: theme.SizeNameText,
+ TextStyle: item.format,
+ }
+ if line.leaked {
+ style.ColorName = colorNameBufferLeaked
+ }
+ texts = append(texts, &widget.TextSegment{
+ Text: item.text,
+ Style: style,
+ })
+ }
+
+ wRichText.Segments = append(wRichText.Segments,
+ &widget.ParagraphSegment{Texts: texts},
+ &widget.TextSegment{Style: widget.RichTextStyleParagraph})
+}
+
+func bufferPrintSeparator() {
+ // TODO(p): Implement our own, so that it can use the primary colour.
+ wRichText.Segments = append(wRichText.Segments,
+ &widget.SeparatorSegment{})
+}
+
+func refreshBuffer(b *buffer) {
+ wRichText.Segments = nil
+
+ markBefore := len(b.lines) - b.newMessages - b.newUnimportantMessages
+ for i, line := range b.lines {
+ if i == markBefore {
+ bufferPrintSeparator()
+ }
+ if !line.isUnimportant || !b.hideUnimportant {
+ bufferPrintLine(b.lines, i)
+ }
+ }
+
+ bufferPrintAndWatchTrailingDateChanges()
+ wRichText.Refresh()
+ bufferScrollToBottom()
+ recheckHighlighted()
+}
+
+// --- Event processing --------------------------------------------------------
+
+func relayProcessBufferLine(b *buffer, m *RelayEventDataBufferLine) {
+ line := convertBufferLine(m)
+
+ // Initial sync: skip all other processing, let highlights be.
+ bc := bufferByName(bufferCurrent)
+ if bc == nil {
+ bufferPushLine(b, line)
+ return
+ }
+
+ // Retained mode is complicated.
+ display := (!m.IsUnimportant || !bc.hideUnimportant) &&
+ (b.bufferName == bufferCurrent || m.LeakToActive)
+ toBottom := display && bufferAtBottom()
+ visible := display && toBottom && inForeground && !wLog.Visible()
+ separate := display &&
+ !visible && bc.newMessages == 0 && bc.newUnimportantMessages == 0
+
+ bufferPushLine(b, line)
+ if !(visible || m.LeakToActive) ||
+ b.newMessages != 0 || b.newUnimportantMessages != 0 {
+ if line.isUnimportant || m.LeakToActive {
+ b.newUnimportantMessages++
+ } else {
+ b.newMessages++
+ }
+ }
+
+ if m.LeakToActive {
+ leakedLine := line
+ leakedLine.leaked = true
+ bufferPushLine(bc, leakedLine)
+
+ if !visible || bc.newMessages != 0 || bc.newUnimportantMessages != 0 {
+ if line.isUnimportant {
+ bc.newUnimportantMessages++
+ } else {
+ bc.newMessages++
+ }
+ }
+ }
+
+ if separate {
+ bufferPrintSeparator()
+ }
+ if display {
+ bufferPrintLine(bc.lines, len(bc.lines)-1)
+ wRichText.Refresh()
+ }
+ if toBottom {
+ bufferScrollToBottom()
+ }
+
+ // TODO(p): On mobile, we should probably send notifications.
+ // Though we probably can't run in the background.
+ if line.isHighlight || (!visible && !line.isUnimportant &&
+ b.kind == RelayBufferKindPrivateMessage) {
+ beep()
+
+ if !visible {
+ b.highlighted = true
+ refreshIcon()
+ }
+ }
+
+ refreshBufferList()
+}
+
+func relayProcessCallbacks(
+ commandSeq uint32, err string, response *RelayResponseData) {
+ if handler, ok := commandCallbacks[commandSeq]; !ok {
+ if *debug {
+ log.Printf("Unawaited response: %+v\n", *response)
+ }
+ } else {
+ delete(commandCallbacks, commandSeq)
+ if handler != nil {
+ handler(err, response)
+ }
+ }
+
+ // We don't particularly care about wraparound issues.
+ for cs, handler := range commandCallbacks {
+ if cs <= commandSeq {
+ delete(commandCallbacks, cs)
+ if handler != nil {
+ handler("No response", nil)
+ }
+ }
+ }
+}
+
+func relayProcessMessage(m *RelayEventMessage) {
+ switch data := m.Data.Variant.(type) {
+ case *RelayEventDataError:
+ relayProcessCallbacks(data.CommandSeq, data.Error, nil)
+ case *RelayEventDataResponse:
+ relayProcessCallbacks(data.CommandSeq, "", &data.Data)
+
+ case *RelayEventDataPing:
+ relaySend(RelayCommandData{
+ Variant: &RelayCommandDataPingResponse{EventSeq: m.EventSeq},
+ }, nil)
+
+ case *RelayEventDataBufferLine:
+ b := bufferByName(data.BufferName)
+ if b == nil {
+ return
+ }
+
+ relayProcessBufferLine(b, data)
+ case *RelayEventDataBufferUpdate:
+ b := bufferByName(data.BufferName)
+ if b == nil {
+ buffers = append(buffers, buffer{bufferName: data.BufferName})
+ b = &buffers[len(buffers)-1]
+ refreshBufferList()
+ }
+
+ hidingToggled := b.hideUnimportant != data.HideUnimportant
+ b.hideUnimportant = data.HideUnimportant
+ b.kind = data.Context.Variant.Kind()
+ b.serverName = ""
+ switch context := data.Context.Variant.(type) {
+ case *RelayBufferContextServer:
+ b.serverName = context.ServerName
+ case *RelayBufferContextChannel:
+ b.serverName = context.ServerName
+ b.modes = context.Modes
+ b.topic = convertItems(context.Topic)
+ case *RelayBufferContextPrivateMessage:
+ b.serverName = context.ServerName
+ }
+
+ if b.bufferName == bufferCurrent {
+ refreshTopic(b.topic)
+ refreshStatus()
+
+ if hidingToggled {
+ refreshBuffer(b)
+ }
+ }
+ case *RelayEventDataBufferStats:
+ b := bufferByName(data.BufferName)
+ if b == nil {
+ return
+ }
+
+ b.newMessages = int(data.NewMessages)
+ b.newUnimportantMessages = int(data.NewUnimportantMessages)
+ b.highlighted = data.Highlighted
+
+ refreshIcon()
+ case *RelayEventDataBufferRename:
+ b := bufferByName(data.BufferName)
+ if b == nil {
+ return
+ }
+
+ b.bufferName = data.New
+
+ if data.BufferName == bufferCurrent {
+ bufferCurrent = data.New
+ refreshStatus()
+ }
+ refreshBufferList()
+ if data.BufferName == bufferLast {
+ bufferLast = data.New
+ }
+ case *RelayEventDataBufferRemove:
+ buffers = slices.DeleteFunc(buffers, func(b buffer) bool {
+ return b.bufferName == data.BufferName
+ })
+
+ refreshBufferList()
+ refreshIcon()
+ case *RelayEventDataBufferActivate:
+ old := bufferByName(bufferCurrent)
+ bufferLast = bufferCurrent
+ bufferCurrent = data.BufferName
+ b := bufferByName(data.BufferName)
+ if b == nil {
+ return
+ }
+
+ if old != nil {
+ old.newMessages = 0
+ old.newUnimportantMessages = 0
+ old.highlighted = false
+
+ old.input = wEntry.Text
+ old.inputRow = wEntry.CursorRow
+ old.inputColumn = wEntry.CursorColumn
+
+ // Note that we effectively overwrite the newest line
+ // with the current textarea contents, and jump there.
+ old.historyAt = len(old.history)
+ }
+
+ if wLog.Visible() {
+ bufferToggleLog()
+ }
+ if inForeground {
+ b.highlighted = false
+ }
+
+ for i := range buffers {
+ if buffers[i].bufferName == bufferCurrent {
+ wBufferList.Select(widget.ListItemID(i))
+ break
+ }
+ }
+
+ refreshIcon()
+ refreshTopic(b.topic)
+ refreshBufferList()
+ refreshBuffer(b)
+ refreshPrompt()
+ refreshStatus()
+
+ wEntry.SetText(b.input)
+ wEntry.CursorRow = b.inputRow
+ wEntry.CursorColumn = b.inputColumn
+ wEntry.Refresh()
+ wWindow.Canvas().Focus(wEntry)
+ case *RelayEventDataBufferInput:
+ b := bufferByName(data.BufferName)
+ if b == nil {
+ return
+ }
+
+ if b.historyAt == len(b.history) {
+ b.historyAt++
+ }
+ b.history = append(b.history, data.Text)
+ case *RelayEventDataBufferClear:
+ b := bufferByName(data.BufferName)
+ if b == nil {
+ return
+ }
+
+ b.lines = nil
+ if b.bufferName == bufferCurrent {
+ refreshBuffer(b)
+ }
+
+ case *RelayEventDataServerUpdate:
+ s, existed := servers[data.ServerName]
+ if !existed {
+ s = &server{}
+ servers[data.ServerName] = s
+ }
+
+ s.state = data.Data.Variant.State()
+ switch state := data.Data.Variant.(type) {
+ case *RelayServerDataRegistered:
+ s.user = state.User
+ s.userModes = state.UserModes
+ default:
+ s.user = ""
+ s.userModes = ""
+ }
+
+ refreshPrompt()
+ case *RelayEventDataServerRename:
+ servers[data.New] = servers[data.ServerName]
+ delete(servers, data.ServerName)
+ case *RelayEventDataServerRemove:
+ delete(servers, data.ServerName)
+ }
+}
+
+// --- Networking --------------------------------------------------------------
+
+func relayMakeReceiver(
+ ctx context.Context, conn net.Conn) <-chan RelayEventMessage {
+ // The usual event message rarely gets above 1 kilobyte,
+ // thus this is set to buffer up at most 1 megabyte or so.
+ p := make(chan RelayEventMessage, 1000)
+ r := bufio.NewReaderSize(conn, 65536)
+ go func() {
+ defer close(p)
+ for {
+ m, ok := relayReadMessage(r)
+ if !ok {
+ return
+ }
+ select {
+ case p <- m:
+ case <-ctx.Done():
+ return
+ }
+ }
+ }()
+ return p
+}
+
+func relayResetState() {
+ commandSeq = 0
+ commandCallbacks = make(map[uint32]callback)
+
+ buffers = nil
+ bufferCurrent = ""
+ bufferLast = ""
+ servers = make(map[string]*server)
+
+ refreshIcon()
+ refreshTopic(nil)
+ refreshBufferList()
+ wRichText.ParseMarkdown("")
+ refreshPrompt()
+ refreshStatus()
+}
+
+func relayRun() {
+ fyne.CurrentApp().Preferences().SetString(preferenceAddress, backendAddress)
+ backendLock.Lock()
+
+ fyne.DoAndWait(func() {
+ relayResetState()
+ })
+
+ backendContext, backendCancel = context.WithCancel(context.Background())
+ defer backendCancel()
+ var err error
+ backendConn, err = net.Dial("tcp", backendAddress)
+
+ backendLock.Unlock()
+ if err != nil {
+ fyne.DoAndWait(func() {
+ wConnect.Show()
+ showErrorMessage("Connection failed: " + err.Error())
+ })
+ return
+ }
+ defer backendConn.Close()
+
+ // TODO(p): Figure out locking.
+ // - Messages are currently sent (semi-)synchronously, directly.
+ // - Is the net.Conn actually async-safe?
+ relaySend(RelayCommandData{
+ Variant: &RelayCommandDataHello{Version: RelayVersion},
+ }, nil)
+
+ relayMessages := relayMakeReceiver(backendContext, backendConn)
+Loop:
+ for {
+ select {
+ case m, ok := <-relayMessages:
+ if !ok {
+ break Loop
+ }
+ fyne.DoAndWait(func() {
+ relayProcessMessage(&m)
+ })
+ }
+ }
+ fyne.DoAndWait(func() {
+ wConnect.Show()
+ showErrorMessage("Disconnected")
+ })
+}
+
+// --- Input line --------------------------------------------------------------
+
+func inputSetContents(input string) {
+ wEntry.SetText(input)
+}
+
+func inputSubmit(text string) bool {
+ b := bufferByName(bufferCurrent)
+ if b == nil {
+ return false
+ }
+
+ b.history = append(b.history, text)
+ b.historyAt = len(b.history)
+ inputSetContents("")
+
+ relaySend(RelayCommandData{Variant: &RelayCommandDataBufferInput{
+ BufferName: b.bufferName,
+ Text: text,
+ }}, nil)
+ return true
+}
+
+type inputStamp struct {
+ cursorRow, cursorColumn int
+ input string
+}
+
+func inputGetStamp() inputStamp {
+ return inputStamp{
+ cursorRow: wEntry.CursorRow,
+ cursorColumn: wEntry.CursorColumn,
+ input: wEntry.Text,
+ }
+}
+
+func inputCompleteFinish(state inputStamp,
+ err string, response *RelayResponseDataBufferComplete) {
+ if response == nil {
+ showErrorMessage(err)
+ return
+ }
+
+ if len(response.Completions) > 0 {
+ insert := response.Completions[0]
+ if len(response.Completions) == 1 {
+ insert += " "
+ }
+ inputSetContents(state.input[:response.Start] + insert)
+
+ }
+ if len(response.Completions) != 1 {
+ beep()
+ }
+
+ // TODO(p): Show all completion options.
+}
+
+func inputComplete() bool {
+ if wEntry.SelectedText() != "" {
+ return false
+ }
+
+ // XXX: Fyne's Entry widget makes it impossible to handle this properly.
+ state := inputGetStamp()
+ relaySend(RelayCommandData{Variant: &RelayCommandDataBufferComplete{
+ BufferName: bufferCurrent,
+ Text: state.input,
+ Position: uint32(len(state.input)),
+ }}, func(err string, response *RelayResponseData) {
+ if stamp := inputGetStamp(); state == stamp {
+ inputCompleteFinish(state,
+ err, response.Variant.(*RelayResponseDataBufferComplete))
+ }
+ })
+ return true
+}
+
+func inputUp() bool {
+ b := bufferByName(bufferCurrent)
+ if b == nil || b.historyAt < 1 {
+ return false
+ }
+
+ if b.historyAt == len(b.history) {
+ b.input = wEntry.Text
+ }
+ b.historyAt--
+ inputSetContents(b.history[b.historyAt])
+ return true
+}
+
+func inputDown() bool {
+ b := bufferByName(bufferCurrent)
+ if b == nil || b.historyAt >= len(b.history) {
+ return false
+ }
+
+ b.historyAt++
+ if b.historyAt == len(b.history) {
+ inputSetContents(b.input)
+ } else {
+ inputSetContents(b.history[b.historyAt])
+ }
+ return true
+}
+
+// --- General UI --------------------------------------------------------------
+
+type inputEntry struct {
+ widget.Entry
+
+ // selectKeyDown is a hack to exactly invert widget.Entry's behaviour,
+ // which groups both Shift keys together.
+ selectKeyDown bool
+}
+
+func newInputEntry() *inputEntry {
+ e := &inputEntry{}
+ e.MultiLine = true
+ e.Wrapping = fyne.TextWrap(fyne.TextTruncateClip)
+ e.ExtendBaseWidget(e)
+ return e
+}
+
+func (e *inputEntry) FocusLost() {
+ e.selectKeyDown = false
+ e.Entry.FocusLost()
+}
+
+func (e *inputEntry) KeyDown(key *fyne.KeyEvent) {
+ // TODO(p): And perhaps on other actions, too.
+ relaySend(RelayCommandData{Variant: &RelayCommandDataActive{}}, nil)
+
+ // Modified events are eaten somewhere, not reaching TypedKey or Shortcuts.
+ if dd, ok := fyne.CurrentApp().Driver().(desktop.Driver); ok {
+ modifiedKey := desktop.CustomShortcut{
+ KeyName: key.Name, Modifier: dd.CurrentKeyModifiers()}
+ if handler := shortcuts[modifiedKey]; handler != nil {
+ handler()
+ return
+ }
+
+ switch {
+ case modifiedKey.Modifier == fyne.KeyModifierControl &&
+ modifiedKey.KeyName == fyne.KeyP:
+ inputUp()
+ return
+ case modifiedKey.Modifier == fyne.KeyModifierControl &&
+ modifiedKey.KeyName == fyne.KeyN:
+ inputDown()
+ return
+ }
+ }
+
+ if key.Name == desktop.KeyShiftLeft || key.Name == desktop.KeyShiftRight {
+ e.selectKeyDown = true
+ }
+ e.Entry.KeyDown(key)
+}
+
+func (e *inputEntry) KeyUp(key *fyne.KeyEvent) {
+ if key.Name == desktop.KeyShiftLeft || key.Name == desktop.KeyShiftRight {
+ e.selectKeyDown = false
+ }
+ e.Entry.KeyUp(key)
+}
+
+func (e *inputEntry) TypedKey(key *fyne.KeyEvent) {
+ if e.Disabled() {
+ return
+ }
+
+ // Invert the Shift key behaviour here.
+ // Notice that this will never work on mobile.
+ shift := &fyne.KeyEvent{Name: desktop.KeyShiftLeft}
+ switch key.Name {
+ case fyne.KeyReturn, fyne.KeyEnter:
+ if e.selectKeyDown {
+ e.Entry.KeyUp(shift)
+ e.Entry.TypedKey(key)
+ e.Entry.KeyDown(shift)
+ } else if e.OnSubmitted != nil {
+ e.OnSubmitted(e.Text)
+ }
+ case fyne.KeyTab:
+ if e.selectKeyDown {
+ // This could also go through completion lists.
+ wWindow.Canvas().FocusPrevious()
+ } else {
+ inputComplete()
+ }
+ default:
+ e.Entry.TypedKey(key)
+ }
+}
+
+func (e *inputEntry) SetText(text string) {
+ e.Entry.SetText(text)
+ if text != "" {
+ e.Entry.TypedKey(&fyne.KeyEvent{Name: fyne.KeyPageDown})
+ }
+}
+
+// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+
+type logEntry struct {
+ // XXX: Sadly, we can't seem to make it actually read-only.
+ // https://github.com/fyne-io/fyne/issues/5263
+ widget.Entry
+}
+
+func newLogEntry() *logEntry {
+ e := &logEntry{}
+ e.MultiLine = true
+ e.Wrapping = fyne.TextWrapWord
+ e.ExtendBaseWidget(e)
+ return e
+}
+
+func (e *logEntry) SetText(text string) {
+ e.OnChanged = nil
+ e.Entry.SetText(text)
+ e.OnChanged = func(string) { e.Entry.SetText(text) }
+}
+
+func (e *logEntry) AcceptsTab() bool {
+ return false
+}
+
+// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+
+type customLayout struct{}
+
+func (l *customLayout) MinSize(objects []fyne.CanvasObject) fyne.Size {
+ var w, h float32 = 0, 0
+ for _, o := range objects {
+ size := o.MinSize()
+ if w < size.Width {
+ w = size.Width
+ }
+ if h < size.Height {
+ h = size.Height
+ }
+ }
+ return fyne.NewSize(w, h)
+}
+
+func (l *customLayout) Layout(objects []fyne.CanvasObject, size fyne.Size) {
+ // It is not otherwise possible to be notified of resizes.
+ // Embedding container.Scroll either directly or as a pointer
+ // to override its Resize method results in brokenness.
+ toBottom := bufferAtBottom()
+ for _, o := range objects {
+ o.Move(fyne.NewPos(0, 0))
+ o.Resize(size)
+ }
+ if toBottom {
+ bufferScrollToBottom()
+ } else {
+ recheckHighlighted()
+ refreshStatus()
+ }
+}
+
+// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+
+// rotatedBuffers returns buffer indexes starting with the current buffer.
+func rotatedBuffers() []int {
+ r, start := make([]int, len(buffers)), 0
+ for i := range buffers {
+ if buffers[i].bufferName == bufferCurrent {
+ start = i
+ break
+ }
+ }
+ for i := range r {
+ start++
+ r[i] = start % len(r)
+ }
+ return r
+}
+
+var shortcuts = map[desktop.CustomShortcut]func(){
+ {
+ KeyName: fyne.KeyPageUp,
+ Modifier: fyne.KeyModifierControl,
+ }: func() {
+ if r := rotatedBuffers(); len(r) <= 0 {
+ } else if i := r[len(r)-1]; i == 0 {
+ bufferActivate(buffers[len(buffers)-1].bufferName)
+ } else {
+ bufferActivate(buffers[i-1].bufferName)
+ }
+ },
+ {
+ KeyName: fyne.KeyPageDown,
+ Modifier: fyne.KeyModifierControl,
+ }: func() {
+ if r := rotatedBuffers(); len(r) <= 0 {
+ } else {
+ bufferActivate(buffers[r[0]].bufferName)
+ }
+ },
+ {
+ KeyName: fyne.KeyTab,
+ Modifier: fyne.KeyModifierAlt,
+ }: func() {
+ if bufferLast != "" {
+ bufferActivate(bufferLast)
+ }
+ },
+ {
+ // XXX: This makes an assumption on the keyboard layout (we want '!').
+ KeyName: fyne.Key1,
+ Modifier: fyne.KeyModifierAlt | fyne.KeyModifierShift,
+ }: func() {
+ for _, i := range rotatedBuffers() {
+ if buffers[i].highlighted {
+ bufferActivate(buffers[i].bufferName)
+ break
+ }
+ }
+ },
+ {
+ KeyName: fyne.KeyA,
+ Modifier: fyne.KeyModifierAlt,
+ }: func() {
+ for _, i := range rotatedBuffers() {
+ if buffers[i].newMessages != 0 {
+ bufferActivate(buffers[i].bufferName)
+ break
+ }
+ }
+ },
+ {
+ KeyName: fyne.KeyH,
+ Modifier: fyne.KeyModifierAlt | fyne.KeyModifierShift,
+ }: func() {
+ if b := bufferByName(bufferCurrent); b != nil {
+ bufferToggleUnimportant(b.bufferName)
+ }
+ },
+ {
+ KeyName: fyne.KeyH,
+ Modifier: fyne.KeyModifierAlt,
+ }: func() {
+ if b := bufferByName(bufferCurrent); b != nil {
+ bufferToggleLog()
+ }
+ },
+}
+
+func main() {
+ flag.Usage = func() {
+ fmt.Fprintf(flag.CommandLine.Output(),
+ "Usage: %s [OPTION...] [CONNECT]\n\n", os.Args[0])
+ flag.PrintDefaults()
+ }
+
+ flag.Parse()
+ if flag.NArg() > 1 {
+ flag.Usage()
+ os.Exit(1)
+ }
+
+ var err error
+ otoContext, otoReady, err = oto.NewContext(&oto.NewContextOptions{
+ SampleRate: 44100,
+ ChannelCount: 1,
+ Format: oto.FormatSignedInt16LE,
+ })
+ if err != nil {
+ log.Println(err)
+ }
+
+ a := app.New()
+ a.Settings().SetTheme(&customTheme{})
+ a.SetIcon(resourceIconNormal)
+ wWindow = a.NewWindow(projectName)
+ wWindow.Resize(fyne.NewSize(640, 480))
+
+ a.Lifecycle().SetOnEnteredForeground(func() {
+ // TODO(p): Does this need locking?
+ inForeground = true
+ recheckHighlighted()
+ })
+ a.Lifecycle().SetOnExitedForeground(func() {
+ inForeground = false
+ })
+
+ // TODO(p): Consider using data bindings.
+ wBufferList = widget.NewList(func() int { return len(buffers) },
+ func() fyne.CanvasObject {
+ return widget.NewLabel(strings.Repeat(" ", 16))
+ },
+ func(id widget.ListItemID, item fyne.CanvasObject) {
+ label, b := item.(*widget.Label), &buffers[int(id)]
+ label.TextStyle.Italic = b.bufferName == bufferCurrent
+ label.TextStyle.Bold = false
+ text := b.bufferName
+ if b.bufferName != bufferCurrent && b.newMessages != 0 {
+ label.TextStyle.Bold = true
+ text += fmt.Sprintf(" (%d)", b.newMessages)
+ }
+ label.Importance = widget.MediumImportance
+ if b.highlighted {
+ label.Importance = widget.HighImportance
+ }
+ label.SetText(text)
+ })
+ wBufferList.HideSeparators = true
+ wBufferList.OnSelected = func(id widget.ListItemID) {
+ // TODO(p): See if we can deselect it now without consequences.
+ request := buffers[int(id)].bufferName
+ if request != bufferCurrent {
+ bufferActivate(request)
+ }
+ }
+
+ wTopic = widget.NewRichText()
+ wTopic.Truncation = fyne.TextTruncateEllipsis
+
+ wRichText = widget.NewRichText()
+ wRichText.Wrapping = fyne.TextWrapWord
+ wRichScroll = container.NewVScroll(wRichText)
+ wRichScroll.OnScrolled = func(position fyne.Position) {
+ recheckHighlighted()
+ refreshStatus()
+ }
+ wLog = newLogEntry()
+ wLog.Wrapping = fyne.TextWrapWord
+ wLog.Hide()
+
+ wPrompt = widget.NewLabelWithStyle(
+ "", fyne.TextAlignLeading, fyne.TextStyle{Bold: true})
+ wDown = widget.NewIcon(theme.MoveDownIcon())
+ wStatus = widget.NewLabelWithStyle(
+ "", fyne.TextAlignTrailing, fyne.TextStyle{})
+ wEntry = newInputEntry()
+ wEntry.OnSubmitted = func(text string) { inputSubmit(text) }
+
+ top := container.NewVBox(
+ wTopic,
+ widget.NewSeparator(),
+ )
+ split := container.NewHSplit(wBufferList,
+ container.New(&customLayout{}, wRichScroll, wLog))
+ split.SetOffset(0.25)
+ bottom := container.NewVBox(
+ widget.NewSeparator(),
+ container.NewBorder(nil, nil,
+ wPrompt, container.NewHBox(wDown, wStatus)),
+ wEntry,
+ )
+ wWindow.SetContent(container.NewBorder(top, bottom, nil, nil, split))
+
+ canvas := wWindow.Canvas()
+ for s, handler := range shortcuts {
+ canvas.AddShortcut(&s, func(fyne.Shortcut) { handler() })
+ }
+
+ // ---
+
+ connect := false
+ backendAddress = a.Preferences().String(preferenceAddress)
+ if flag.NArg() >= 1 {
+ backendAddress = flag.Arg(0)
+ connect = true
+ }
+
+ connectAddress := widget.NewEntry()
+ connectAddress.SetPlaceHolder("host:port")
+ connectAddress.SetText(backendAddress)
+ connectAddress.TypedKey(&fyne.KeyEvent{Name: fyne.KeyPageDown})
+ connectAddress.Validator = func(text string) error {
+ _, _, err := net.SplitHostPort(text)
+ return err
+ }
+
+ // TODO(p): Mobile should not have the option to cancel at all.
+ // The GoBack just makes us go to the background, staying useless.
+ wConnect = dialog.NewForm("Connect to relay", "Connect", "Exit",
+ []*widget.FormItem{
+ {Text: "Address:", Widget: connectAddress},
+ }, func(ok bool) {
+ if ok {
+ backendAddress = connectAddress.Text
+ go relayRun()
+ } else if md, ok := a.Driver().(mobile.Driver); ok {
+ md.GoBack()
+ wConnect.Show()
+ } else {
+ a.Quit()
+ }
+ }, wWindow)
+ if connect {
+ go relayRun()
+ } else {
+ wConnect.Show()
+ }
+
+ wWindow.ShowAndRun()
+}
diff --git a/xA/xA.svg b/xA/xA.svg
new file mode 100644
index 0000000..1e7bfd1
--- /dev/null
+++ b/xA/xA.svg
@@ -0,0 +1,23 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg version="1.1" width="512" height="512" viewBox="0 0 512 512"
+ xmlns="http://www.w3.org/2000/svg">
+
+ <defs>
+ <linearGradient id="background" x1="0" y1="0" x2="0" y2="1">
+ <stop stop-color="#ffaa00" offset="0" />
+ <stop stop-color="#ff0000" offset="1" />
+ </linearGradient>
+ <clipPath id="clip">
+ <rect x="0" y="0" width="1" height="1" />
+ </clipPath>
+ </defs>
+
+ <rect x="0" y="0" width="512" height="512" fill="url(#background)" />
+
+ <g transform="translate(64, 64) scale(384)" stroke-linecap="square">
+ <g clip-path="url(#clip)">
+ <path stroke="#ffffff" stroke-width="0.125"
+ d="M 0.25,1.1 0.65,-0.1 M 0.75,1.1 0.35,-0.1 M 0.225,0.75 0.775,0.75" />
+ </g>
+ </g>
+</svg>
diff --git a/xC.c b/xC.c
index 2f6cf85..d79b600 100644
--- a/xC.c
+++ b/xC.c
@@ -474,6 +474,10 @@ input_rl_start (void *input, const char *program_name)
// autofilter, and we don't generally want alphabetic ordering at all
rl_sort_completion_matches = false;
+ // Readline >= 8.0 otherwise prints spurious newlines on EOF.
+ if (RL_VERSION_MAJOR >= 8)
+ rl_variable_bind ("enable-bracketed-paste", "off");
+
hard_assert (self->prompt != NULL);
// The inputrc is read before any callbacks are called, so we need to
// register all functions that our user may want to map up front
@@ -1814,6 +1818,7 @@ struct client
uint32_t event_seq; ///< Outgoing message counter
bool initialized; ///< Initial sync took place
+ bool closing; ///< We're closing the connection
struct poller_fd socket_event; ///< The socket can be read/written to
};
@@ -1871,7 +1876,7 @@ enum server_state
IRC_CONNECTED, ///< Trying to register
IRC_REGISTERED, ///< We can chat now
IRC_CLOSING, ///< Flushing output before shutdown
- IRC_HALF_CLOSED ///< Connection shutdown from our side
+ IRC_HALF_CLOSED ///< Connection shut down from our side
};
/// Convert an IRC identifier character to lower-case
@@ -2259,14 +2264,6 @@ struct app_context
struct str_map servers; ///< Our servers
- // Relay:
-
- int relay_fd; ///< Listening socket FD
- struct client *clients; ///< Our relay clients
-
- /// A single message buffer to prepare all outcoming messages within
- struct relay_event_message relay_message;
-
// Events:
struct poller_fd tty_event; ///< Terminal input event
@@ -2318,6 +2315,14 @@ struct app_context
char *editor_filename; ///< The file being edited by user
int terminal_suspended; ///< Terminal suspension level
+ // Relay:
+
+ int relay_fd; ///< Listening socket FD
+ struct client *clients; ///< Our relay clients
+
+ /// A single message buffer to prepare all outcoming messages within
+ struct relay_event_message relay_message;
+
// Plugins:
struct plugin *plugins; ///< Loaded plugins
@@ -2388,8 +2393,6 @@ app_context_init (struct app_context *self)
self->config = config_make ();
poller_init (&self->poller);
- self->relay_fd = -1;
-
self->servers = str_map_make ((str_map_free_fn) server_unref);
self->servers.key_xfrm = tolower_ascii_strxfrm;
@@ -2413,6 +2416,8 @@ app_context_init (struct app_context *self)
self->nick_palette =
filter_color_cube_for_acceptable_nick_colors (&self->nick_palette_len);
+
+ self->relay_fd = -1;
}
static void
@@ -2891,390 +2896,6 @@ serialize_configuration (struct config_item *root, struct str *output)
config_item_write (root, true, output);
}
-// --- Relay output ------------------------------------------------------------
-
-static void
-client_kill (struct client *c)
-{
- struct app_context *ctx = c->ctx;
- poller_fd_reset (&c->socket_event);
- xclose (c->socket_fd);
- c->socket_fd = -1;
-
- LIST_UNLINK (ctx->clients, c);
- client_destroy (c);
-}
-
-static void
-client_update_poller (struct client *c, const struct pollfd *pfd)
-{
- int new_events = POLLIN;
- if (c->write_buffer.len)
- new_events |= POLLOUT;
-
- hard_assert (new_events != 0);
- if (!pfd || pfd->events != new_events)
- poller_fd_set (&c->socket_event, new_events);
-}
-
-// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
-static void
-relay_send (struct client *c)
-{
- struct relay_event_message *m = &c->ctx->relay_message;
- m->event_seq = c->event_seq++;
-
- // TODO: Also don't try sending anything if half-closed.
- if (!c->initialized || c->socket_fd == -1)
- return;
-
- // liberty has msg_{reader,writer} already, but they use 8-byte lengths.
- size_t frame_len_pos = c->write_buffer.len, frame_len = 0;
- str_pack_u32 (&c->write_buffer, 0);
- if (!relay_event_message_serialize (m, &c->write_buffer)
- || (frame_len = c->write_buffer.len - frame_len_pos - 4) > UINT32_MAX)
- {
- print_error ("serialization failed, killing client");
- client_kill (c);
- return;
- }
-
- uint32_t len = htonl (frame_len);
- memcpy (c->write_buffer.str + frame_len_pos, &len, sizeof len);
- client_update_poller (c, NULL);
-}
-
-static void
-relay_broadcast_except (struct app_context *ctx, struct client *exception)
-{
- LIST_FOR_EACH (struct client, c, ctx->clients)
- if (c != exception)
- relay_send (c);
-}
-
-#define relay_broadcast(ctx) relay_broadcast_except ((ctx), NULL)
-
-static struct relay_event_message *
-relay_prepare (struct app_context *ctx)
-{
- struct relay_event_message *m = &ctx->relay_message;
- relay_event_message_free (m);
- memset (m, 0, sizeof *m);
- return m;
-}
-
-static void
-relay_prepare_ping (struct app_context *ctx)
-{
- relay_prepare (ctx)->data.event = RELAY_EVENT_PING;
-}
-
-static union relay_item_data *
-relay_translate_formatter (struct app_context *ctx, union relay_item_data *p,
- const struct formatter_item *i)
-{
- // XXX: See attr_printer_decode_color(), this is a footgun.
- int16_t c16 = i->color;
- int16_t c256 = i->color >> 16;
-
- unsigned attrs = i->attribute;
- switch (i->type)
- {
- case FORMATTER_ITEM_TEXT:
- p->text.text = str_from_cstr (i->text);
- (p++)->kind = RELAY_ITEM_TEXT;
- break;
- case FORMATTER_ITEM_FG_COLOR:
- p->fg_color.color = c256 <= 0 ? c16 : c256;
- (p++)->kind = RELAY_ITEM_FG_COLOR;
- break;
- case FORMATTER_ITEM_BG_COLOR:
- p->bg_color.color = c256 <= 0 ? c16 : c256;
- (p++)->kind = RELAY_ITEM_BG_COLOR;
- break;
- case FORMATTER_ITEM_ATTR:
- (p++)->kind = RELAY_ITEM_RESET;
- if ((c256 = ctx->theme[i->attribute].fg) >= 0)
- {
- p->fg_color.color = c256;
- (p++)->kind = RELAY_ITEM_FG_COLOR;
- }
- if ((c256 = ctx->theme[i->attribute].bg) >= 0)
- {
- p->bg_color.color = c256;
- (p++)->kind = RELAY_ITEM_BG_COLOR;
- }
-
- attrs = ctx->theme[i->attribute].attrs;
- // Fall-through
- case FORMATTER_ITEM_SIMPLE:
- if (attrs & TEXT_BOLD)
- (p++)->kind = RELAY_ITEM_FLIP_BOLD;
- if (attrs & TEXT_ITALIC)
- (p++)->kind = RELAY_ITEM_FLIP_ITALIC;
- if (attrs & TEXT_UNDERLINE)
- (p++)->kind = RELAY_ITEM_FLIP_UNDERLINE;
- if (attrs & TEXT_INVERSE)
- (p++)->kind = RELAY_ITEM_FLIP_INVERSE;
- if (attrs & TEXT_CROSSED_OUT)
- (p++)->kind = RELAY_ITEM_FLIP_CROSSED_OUT;
- if (attrs & TEXT_MONOSPACE)
- (p++)->kind = RELAY_ITEM_FLIP_MONOSPACE;
- break;
- default:
- break;
- }
- return p;
-}
-
-static union relay_item_data *
-relay_items (struct app_context *ctx, const struct formatter_item *items,
- uint32_t *len)
-{
- size_t items_len = 0;
- for (size_t i = 0; items[i].type; i++)
- items_len++;
-
- // Beware of the upper bound, currently dominated by FORMATTER_ITEM_ATTR.
- union relay_item_data *a = xcalloc (items_len * 9, sizeof *a), *p = a;
- for (const struct formatter_item *i = items; items_len--; i++)
- p = relay_translate_formatter (ctx, p, i);
-
- *len = p - a;
- return a;
-}
-
-static void
-relay_prepare_buffer_line (struct app_context *ctx, struct buffer *buffer,
- struct buffer_line *line, bool leak_to_active)
-{
- struct relay_event_message *m = relay_prepare (ctx);
- struct relay_event_data_buffer_line *e = &m->data.buffer_line;
- e->event = RELAY_EVENT_BUFFER_LINE;
- e->buffer_name = str_from_cstr (buffer->name);
- e->is_unimportant = !!(line->flags & BUFFER_LINE_UNIMPORTANT);
- e->is_highlight = !!(line->flags & BUFFER_LINE_HIGHLIGHT);
- e->rendition = 1 + line->r;
- e->when = line->when * 1000;
- e->leak_to_active = leak_to_active;
- e->items = relay_items (ctx, line->items, &e->items_len);
-}
-
-// TODO: Consider pushing this whole block of code much further down.
-static void formatter_add (struct formatter *self, const char *format, ...);
-static char *irc_to_utf8 (const char *text);
-
-static void
-relay_prepare_channel_buffer_update (struct app_context *ctx,
- struct buffer *buffer, struct relay_buffer_context_channel *e)
-{
- struct channel *channel = buffer->channel;
- struct formatter f = formatter_make (ctx, buffer->server);
- if (channel->topic)
- formatter_add (&f, "#m", channel->topic);
- e->topic = relay_items (ctx, f.items, &e->topic_len);
- formatter_free (&f);
-
- // As in make_prompt(), conceal the last known channel modes.
- // XXX: This should use irc_channel_is_joined().
- if (!channel->users_len)
- return;
-
- struct str modes = str_make ();
- str_append_str (&modes, &channel->no_param_modes);
-
- struct str params = str_make ();
- struct str_map_iter iter = str_map_iter_make (&channel->param_modes);
- const char *param;
- while ((param = str_map_iter_next (&iter)))
- {
- str_append_c (&modes, iter.link->key[0]);
- str_append_c (&params, ' ');
- str_append (&params, param);
- }
-
- str_append_str (&modes, &params);
- str_free (&params);
-
- char *modes_utf8 = irc_to_utf8 (modes.str);
- str_free (&modes);
- e->modes = str_from_cstr (modes_utf8);
- free (modes_utf8);
-}
-
-static void
-relay_prepare_buffer_update (struct app_context *ctx, struct buffer *buffer)
-{
- struct relay_event_message *m = relay_prepare (ctx);
- struct relay_event_data_buffer_update *e = &m->data.buffer_update;
- e->event = RELAY_EVENT_BUFFER_UPDATE;
- e->buffer_name = str_from_cstr (buffer->name);
- e->hide_unimportant = buffer->hide_unimportant;
-
- struct str *server_name = NULL;
- switch (buffer->type)
- {
- case BUFFER_GLOBAL:
- e->context.kind = RELAY_BUFFER_KIND_GLOBAL;
- break;
- case BUFFER_SERVER:
- e->context.kind = RELAY_BUFFER_KIND_SERVER;
- server_name = &e->context.server.server_name;
- break;
- case BUFFER_CHANNEL:
- e->context.kind = RELAY_BUFFER_KIND_CHANNEL;
- server_name = &e->context.channel.server_name;
- relay_prepare_channel_buffer_update (ctx, buffer, &e->context.channel);
- break;
- case BUFFER_PM:
- e->context.kind = RELAY_BUFFER_KIND_PRIVATE_MESSAGE;
- server_name = &e->context.private_message.server_name;
- break;
- }
- if (server_name)
- *server_name = str_from_cstr (buffer->server->name);
-}
-
-static void
-relay_prepare_buffer_stats (struct app_context *ctx, struct buffer *buffer)
-{
- struct relay_event_message *m = relay_prepare (ctx);
- struct relay_event_data_buffer_stats *e = &m->data.buffer_stats;
- e->event = RELAY_EVENT_BUFFER_STATS;
- e->buffer_name = str_from_cstr (buffer->name);
- e->new_messages = MIN (UINT32_MAX,
- buffer->new_messages_count - buffer->new_unimportant_count);
- e->new_unimportant_messages = MIN (UINT32_MAX,
- buffer->new_unimportant_count);
- e->highlighted = buffer->highlighted;
-}
-
-static void
-relay_prepare_buffer_rename (struct app_context *ctx, struct buffer *buffer,
- const char *new_name)
-{
- struct relay_event_message *m = relay_prepare (ctx);
- struct relay_event_data_buffer_rename *e = &m->data.buffer_rename;
- e->event = RELAY_EVENT_BUFFER_RENAME;
- e->buffer_name = str_from_cstr (buffer->name);
- e->new = str_from_cstr (new_name);
-}
-
-static void
-relay_prepare_buffer_remove (struct app_context *ctx, struct buffer *buffer)
-{
- struct relay_event_message *m = relay_prepare (ctx);
- struct relay_event_data_buffer_remove *e = &m->data.buffer_remove;
- e->event = RELAY_EVENT_BUFFER_REMOVE;
- e->buffer_name = str_from_cstr (buffer->name);
-}
-
-static void
-relay_prepare_buffer_activate (struct app_context *ctx, struct buffer *buffer)
-{
- struct relay_event_message *m = relay_prepare (ctx);
- struct relay_event_data_buffer_activate *e = &m->data.buffer_activate;
- e->event = RELAY_EVENT_BUFFER_ACTIVATE;
- e->buffer_name = str_from_cstr (buffer->name);
-}
-
-static void
-relay_prepare_buffer_input (struct app_context *ctx, struct buffer *buffer,
- const char *input)
-{
- struct relay_event_message *m = relay_prepare (ctx);
- struct relay_event_data_buffer_input *e = &m->data.buffer_input;
- e->event = RELAY_EVENT_BUFFER_INPUT;
- e->buffer_name = str_from_cstr (buffer->name);
- e->text = str_from_cstr (input);
-}
-
-static void
-relay_prepare_buffer_clear (struct app_context *ctx,
- struct buffer *buffer)
-{
- struct relay_event_message *m = relay_prepare (ctx);
- struct relay_event_data_buffer_clear *e = &m->data.buffer_clear;
- e->event = RELAY_EVENT_BUFFER_CLEAR;
- e->buffer_name = str_from_cstr (buffer->name);
-}
-
-enum relay_server_state
-relay_server_state_for_server (struct server *s)
-{
- switch (s->state)
- {
- case IRC_DISCONNECTED: return RELAY_SERVER_STATE_DISCONNECTED;
- case IRC_CONNECTING: return RELAY_SERVER_STATE_CONNECTING;
- case IRC_CONNECTED: return RELAY_SERVER_STATE_CONNECTED;
- case IRC_REGISTERED: return RELAY_SERVER_STATE_REGISTERED;
- case IRC_CLOSING:
- case IRC_HALF_CLOSED: return RELAY_SERVER_STATE_DISCONNECTING;
- }
- return 0;
-}
-
-static void
-relay_prepare_server_update (struct app_context *ctx, struct server *s)
-{
- struct relay_event_message *m = relay_prepare (ctx);
- struct relay_event_data_server_update *e = &m->data.server_update;
- e->event = RELAY_EVENT_SERVER_UPDATE;
- e->server_name = str_from_cstr (s->name);
- e->data.state = relay_server_state_for_server (s);
- if (s->state == IRC_REGISTERED)
- {
- char *user_utf8 = irc_to_utf8 (s->irc_user->nickname);
- e->data.registered.user = str_from_cstr (user_utf8);
- free (user_utf8);
-
- char *user_modes_utf8 = irc_to_utf8 (s->irc_user_modes.str);
- e->data.registered.user_modes = str_from_cstr (user_modes_utf8);
- free (user_modes_utf8);
- }
-}
-
-static void
-relay_prepare_server_rename (struct app_context *ctx, struct server *s,
- const char *new_name)
-{
- struct relay_event_message *m = relay_prepare (ctx);
- struct relay_event_data_server_rename *e = &m->data.server_rename;
- e->event = RELAY_EVENT_SERVER_RENAME;
- e->server_name = str_from_cstr (s->name);
- e->new = str_from_cstr (new_name);
-}
-
-static void
-relay_prepare_server_remove (struct app_context *ctx, struct server *s)
-{
- struct relay_event_message *m = relay_prepare (ctx);
- struct relay_event_data_server_remove *e = &m->data.server_remove;
- e->event = RELAY_EVENT_SERVER_REMOVE;
- e->server_name = str_from_cstr (s->name);
-}
-
-static void
-relay_prepare_error (struct app_context *ctx, uint32_t seq, const char *message)
-{
- struct relay_event_message *m = relay_prepare (ctx);
- struct relay_event_data_error *e = &m->data.error;
- e->event = RELAY_EVENT_ERROR;
- e->command_seq = seq;
- e->error = str_from_cstr (message);
-}
-
-static struct relay_event_data_response *
-relay_prepare_response (struct app_context *ctx, uint32_t seq)
-{
- struct relay_event_message *m = relay_prepare (ctx);
- struct relay_event_data_response *e = &m->data.response;
- e->event = RELAY_EVENT_RESPONSE;
- e->command_seq = seq;
- return e;
-}
-
// --- Terminal output ---------------------------------------------------------
/// Default colour pair
@@ -4515,6 +4136,394 @@ formatter_flush (struct formatter *self, FILE *stream, int flush_opts)
attr_printer_reset (&state);
}
+// --- Relay output ------------------------------------------------------------
+
+static void
+client_kill (struct client *c)
+{
+ struct app_context *ctx = c->ctx;
+ poller_fd_reset (&c->socket_event);
+ xclose (c->socket_fd);
+ c->socket_fd = -1;
+
+ LIST_UNLINK (ctx->clients, c);
+ client_destroy (c);
+}
+
+static void
+client_update_poller (struct client *c, const struct pollfd *pfd)
+{
+ // In case of closing without any data in the write buffer,
+ // we don't actually need to be able to write to the socket,
+ // but the condition should be quick to satisfy.
+ int new_events = POLLIN;
+ if (c->write_buffer.len || c->closing)
+ new_events |= POLLOUT;
+
+ hard_assert (new_events != 0);
+ if (!pfd || pfd->events != new_events)
+ poller_fd_set (&c->socket_event, new_events);
+}
+
+// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+
+static void
+relay_send (struct client *c)
+{
+ struct relay_event_message *m = &c->ctx->relay_message;
+ m->event_seq = c->event_seq++;
+ if (!c->initialized || c->closing || c->socket_fd == -1)
+ return;
+
+ // liberty has msg_{reader,writer} already, but they use 8-byte lengths.
+ size_t frame_len_pos = c->write_buffer.len, frame_len = 0;
+ str_pack_u32 (&c->write_buffer, 0);
+ if (!relay_event_message_serialize (m, &c->write_buffer)
+ || (frame_len = c->write_buffer.len - frame_len_pos - 4) > UINT32_MAX)
+ {
+ print_error ("serialization failed, killing client");
+
+ // We can't kill the client immediately,
+ // because more relay_send() calls may follow.
+ c->write_buffer.len = frame_len_pos;
+ c->closing = true;
+ }
+ else
+ {
+ uint32_t len = htonl (frame_len);
+ memcpy (c->write_buffer.str + frame_len_pos, &len, sizeof len);
+ }
+
+ client_update_poller (c, NULL);
+}
+
+static void
+relay_broadcast_except (struct app_context *ctx, struct client *exception)
+{
+ LIST_FOR_EACH (struct client, c, ctx->clients)
+ if (c != exception)
+ relay_send (c);
+}
+
+#define relay_broadcast(ctx) relay_broadcast_except ((ctx), NULL)
+
+static struct relay_event_message *
+relay_prepare (struct app_context *ctx)
+{
+ struct relay_event_message *m = &ctx->relay_message;
+ relay_event_message_free (m);
+ memset (m, 0, sizeof *m);
+ return m;
+}
+
+static void
+relay_prepare_ping (struct app_context *ctx)
+{
+ relay_prepare (ctx)->data.event = RELAY_EVENT_PING;
+}
+
+static union relay_item_data *
+relay_translate_formatter (struct app_context *ctx, union relay_item_data *p,
+ const struct formatter_item *i)
+{
+ // XXX: See attr_printer_decode_color(), this is a footgun.
+ int16_t c16 = i->color;
+ int16_t c256 = i->color >> 16;
+
+ unsigned attrs = i->attribute;
+ switch (i->type)
+ {
+ case FORMATTER_ITEM_TEXT:
+ p->text.text = str_from_cstr (i->text);
+ (p++)->kind = RELAY_ITEM_TEXT;
+ break;
+ case FORMATTER_ITEM_FG_COLOR:
+ p->fg_color.color = c256 <= 0 ? c16 : c256;
+ (p++)->kind = RELAY_ITEM_FG_COLOR;
+ break;
+ case FORMATTER_ITEM_BG_COLOR:
+ p->bg_color.color = c256 <= 0 ? c16 : c256;
+ (p++)->kind = RELAY_ITEM_BG_COLOR;
+ break;
+ case FORMATTER_ITEM_ATTR:
+ (p++)->kind = RELAY_ITEM_RESET;
+ if ((c256 = ctx->theme[i->attribute].fg) >= 0)
+ {
+ p->fg_color.color = c256;
+ (p++)->kind = RELAY_ITEM_FG_COLOR;
+ }
+ if ((c256 = ctx->theme[i->attribute].bg) >= 0)
+ {
+ p->bg_color.color = c256;
+ (p++)->kind = RELAY_ITEM_BG_COLOR;
+ }
+
+ attrs = ctx->theme[i->attribute].attrs;
+ // Fall-through
+ case FORMATTER_ITEM_SIMPLE:
+ if (attrs & TEXT_BOLD)
+ (p++)->kind = RELAY_ITEM_FLIP_BOLD;
+ if (attrs & TEXT_ITALIC)
+ (p++)->kind = RELAY_ITEM_FLIP_ITALIC;
+ if (attrs & TEXT_UNDERLINE)
+ (p++)->kind = RELAY_ITEM_FLIP_UNDERLINE;
+ if (attrs & TEXT_INVERSE)
+ (p++)->kind = RELAY_ITEM_FLIP_INVERSE;
+ if (attrs & TEXT_CROSSED_OUT)
+ (p++)->kind = RELAY_ITEM_FLIP_CROSSED_OUT;
+ if (attrs & TEXT_MONOSPACE)
+ (p++)->kind = RELAY_ITEM_FLIP_MONOSPACE;
+ break;
+ default:
+ break;
+ }
+ return p;
+}
+
+static union relay_item_data *
+relay_items (struct app_context *ctx, const struct formatter_item *items,
+ uint32_t *len)
+{
+ size_t items_len = 0;
+ for (size_t i = 0; items[i].type; i++)
+ items_len++;
+
+ // Beware of the upper bound, currently dominated by FORMATTER_ITEM_ATTR.
+ union relay_item_data *a = xcalloc (items_len * 9, sizeof *a), *p = a;
+ for (const struct formatter_item *i = items; items_len--; i++)
+ p = relay_translate_formatter (ctx, p, i);
+
+ *len = p - a;
+ return a;
+}
+
+static void
+relay_prepare_buffer_line (struct app_context *ctx, struct buffer *buffer,
+ struct buffer_line *line, bool leak_to_active)
+{
+ struct relay_event_message *m = relay_prepare (ctx);
+ struct relay_event_data_buffer_line *e = &m->data.buffer_line;
+ e->event = RELAY_EVENT_BUFFER_LINE;
+ e->buffer_name = str_from_cstr (buffer->name);
+ e->is_unimportant = !!(line->flags & BUFFER_LINE_UNIMPORTANT);
+ e->is_highlight = !!(line->flags & BUFFER_LINE_HIGHLIGHT);
+ e->rendition = 1 + line->r;
+ e->when = line->when * 1000;
+ e->leak_to_active = leak_to_active;
+ e->items = relay_items (ctx, line->items, &e->items_len);
+}
+
+static void
+relay_prepare_channel_buffer_update (struct app_context *ctx,
+ struct buffer *buffer, struct relay_buffer_context_channel *e)
+{
+ struct channel *channel = buffer->channel;
+ struct formatter f = formatter_make (ctx, buffer->server);
+ if (channel->topic)
+ formatter_add (&f, "#m", channel->topic);
+ FORMATTER_ADD_ITEM (&f, END);
+ e->topic = relay_items (ctx, f.items, &e->topic_len);
+ formatter_free (&f);
+
+ // As in make_prompt(), conceal the last known channel modes.
+ // XXX: This should use irc_channel_is_joined().
+ if (!channel->users_len)
+ return;
+
+ struct str modes = str_make ();
+ str_append_str (&modes, &channel->no_param_modes);
+
+ struct str params = str_make ();
+ struct str_map_iter iter = str_map_iter_make (&channel->param_modes);
+ const char *param;
+ while ((param = str_map_iter_next (&iter)))
+ {
+ str_append_c (&modes, iter.link->key[0]);
+ str_append_c (&params, ' ');
+ str_append (&params, param);
+ }
+
+ str_append_str (&modes, &params);
+ str_free (&params);
+
+ char *modes_utf8 = irc_to_utf8 (modes.str);
+ str_free (&modes);
+ e->modes = str_from_cstr (modes_utf8);
+ free (modes_utf8);
+}
+
+static void
+relay_prepare_buffer_update (struct app_context *ctx, struct buffer *buffer)
+{
+ struct relay_event_message *m = relay_prepare (ctx);
+ struct relay_event_data_buffer_update *e = &m->data.buffer_update;
+ e->event = RELAY_EVENT_BUFFER_UPDATE;
+ e->buffer_name = str_from_cstr (buffer->name);
+ e->hide_unimportant = buffer->hide_unimportant;
+
+ struct str *server_name = NULL;
+ switch (buffer->type)
+ {
+ case BUFFER_GLOBAL:
+ e->context.kind = RELAY_BUFFER_KIND_GLOBAL;
+ break;
+ case BUFFER_SERVER:
+ e->context.kind = RELAY_BUFFER_KIND_SERVER;
+ server_name = &e->context.server.server_name;
+ break;
+ case BUFFER_CHANNEL:
+ e->context.kind = RELAY_BUFFER_KIND_CHANNEL;
+ server_name = &e->context.channel.server_name;
+ relay_prepare_channel_buffer_update (ctx, buffer, &e->context.channel);
+ break;
+ case BUFFER_PM:
+ e->context.kind = RELAY_BUFFER_KIND_PRIVATE_MESSAGE;
+ server_name = &e->context.private_message.server_name;
+ break;
+ }
+ if (server_name)
+ *server_name = str_from_cstr (buffer->server->name);
+}
+
+static void
+relay_prepare_buffer_stats (struct app_context *ctx, struct buffer *buffer)
+{
+ struct relay_event_message *m = relay_prepare (ctx);
+ struct relay_event_data_buffer_stats *e = &m->data.buffer_stats;
+ e->event = RELAY_EVENT_BUFFER_STATS;
+ e->buffer_name = str_from_cstr (buffer->name);
+ e->new_messages = MIN (UINT32_MAX,
+ buffer->new_messages_count - buffer->new_unimportant_count);
+ e->new_unimportant_messages = MIN (UINT32_MAX,
+ buffer->new_unimportant_count);
+ e->highlighted = buffer->highlighted;
+}
+
+static void
+relay_prepare_buffer_rename (struct app_context *ctx, struct buffer *buffer,
+ const char *new_name)
+{
+ struct relay_event_message *m = relay_prepare (ctx);
+ struct relay_event_data_buffer_rename *e = &m->data.buffer_rename;
+ e->event = RELAY_EVENT_BUFFER_RENAME;
+ e->buffer_name = str_from_cstr (buffer->name);
+ e->new = str_from_cstr (new_name);
+}
+
+static void
+relay_prepare_buffer_remove (struct app_context *ctx, struct buffer *buffer)
+{
+ struct relay_event_message *m = relay_prepare (ctx);
+ struct relay_event_data_buffer_remove *e = &m->data.buffer_remove;
+ e->event = RELAY_EVENT_BUFFER_REMOVE;
+ e->buffer_name = str_from_cstr (buffer->name);
+}
+
+static void
+relay_prepare_buffer_activate (struct app_context *ctx, struct buffer *buffer)
+{
+ struct relay_event_message *m = relay_prepare (ctx);
+ struct relay_event_data_buffer_activate *e = &m->data.buffer_activate;
+ e->event = RELAY_EVENT_BUFFER_ACTIVATE;
+ e->buffer_name = str_from_cstr (buffer->name);
+}
+
+static void
+relay_prepare_buffer_input (struct app_context *ctx, struct buffer *buffer,
+ const char *input)
+{
+ struct relay_event_message *m = relay_prepare (ctx);
+ struct relay_event_data_buffer_input *e = &m->data.buffer_input;
+ e->event = RELAY_EVENT_BUFFER_INPUT;
+ e->buffer_name = str_from_cstr (buffer->name);
+ e->text = str_from_cstr (input);
+}
+
+static void
+relay_prepare_buffer_clear (struct app_context *ctx,
+ struct buffer *buffer)
+{
+ struct relay_event_message *m = relay_prepare (ctx);
+ struct relay_event_data_buffer_clear *e = &m->data.buffer_clear;
+ e->event = RELAY_EVENT_BUFFER_CLEAR;
+ e->buffer_name = str_from_cstr (buffer->name);
+}
+
+enum relay_server_state
+relay_server_state_for_server (struct server *s)
+{
+ switch (s->state)
+ {
+ case IRC_DISCONNECTED: return RELAY_SERVER_STATE_DISCONNECTED;
+ case IRC_CONNECTING: return RELAY_SERVER_STATE_CONNECTING;
+ case IRC_CONNECTED: return RELAY_SERVER_STATE_CONNECTED;
+ case IRC_REGISTERED: return RELAY_SERVER_STATE_REGISTERED;
+ case IRC_CLOSING:
+ case IRC_HALF_CLOSED: return RELAY_SERVER_STATE_DISCONNECTING;
+ }
+ return 0;
+}
+
+static void
+relay_prepare_server_update (struct app_context *ctx, struct server *s)
+{
+ struct relay_event_message *m = relay_prepare (ctx);
+ struct relay_event_data_server_update *e = &m->data.server_update;
+ e->event = RELAY_EVENT_SERVER_UPDATE;
+ e->server_name = str_from_cstr (s->name);
+ e->data.state = relay_server_state_for_server (s);
+ if (s->state == IRC_REGISTERED)
+ {
+ char *user_utf8 = irc_to_utf8 (s->irc_user->nickname);
+ e->data.registered.user = str_from_cstr (user_utf8);
+ free (user_utf8);
+
+ char *user_modes_utf8 = irc_to_utf8 (s->irc_user_modes.str);
+ e->data.registered.user_modes = str_from_cstr (user_modes_utf8);
+ free (user_modes_utf8);
+ }
+}
+
+static void
+relay_prepare_server_rename (struct app_context *ctx, struct server *s,
+ const char *new_name)
+{
+ struct relay_event_message *m = relay_prepare (ctx);
+ struct relay_event_data_server_rename *e = &m->data.server_rename;
+ e->event = RELAY_EVENT_SERVER_RENAME;
+ e->server_name = str_from_cstr (s->name);
+ e->new = str_from_cstr (new_name);
+}
+
+static void
+relay_prepare_server_remove (struct app_context *ctx, struct server *s)
+{
+ struct relay_event_message *m = relay_prepare (ctx);
+ struct relay_event_data_server_remove *e = &m->data.server_remove;
+ e->event = RELAY_EVENT_SERVER_REMOVE;
+ e->server_name = str_from_cstr (s->name);
+}
+
+static void
+relay_prepare_error (struct app_context *ctx, uint32_t seq, const char *message)
+{
+ struct relay_event_message *m = relay_prepare (ctx);
+ struct relay_event_data_error *e = &m->data.error;
+ e->event = RELAY_EVENT_ERROR;
+ e->command_seq = seq;
+ e->error = str_from_cstr (message);
+}
+
+static struct relay_event_data_response *
+relay_prepare_response (struct app_context *ctx, uint32_t seq)
+{
+ struct relay_event_message *m = relay_prepare (ctx);
+ struct relay_event_data_response *e = &m->data.response;
+ e->event = RELAY_EVENT_RESPONSE;
+ e->command_seq = seq;
+ return e;
+}
+
// --- Buffers -----------------------------------------------------------------
static void
@@ -7033,9 +7042,7 @@ irc_is_highlight (struct server *s, const char *message)
cstr_transform (nick, s->irc_tolower);
// Special characters allowed in nicknames by RFC 2812: []\`_^{|} and -
- // Also excluded from the ASCII: common user channel prefixes: +%@&~
- // XXX: why did I exclude those? It won't match when IRC newbies use them.
- const char *delimiters = ",.;:!?()<>/=#$* \t\r\n\v\f\"'";
+ const char *delimiters = "\t\n\v\f\r !\"#$%&'()*+,./:;<=>?@~";
bool result = false;
char *save = NULL;
@@ -14603,21 +14610,23 @@ on_readline_input (char *line)
if (*line)
add_history (line);
- // readline always erases the input line after returning from here,
+ // Readline always erases the input line after returning from here,
// but we don't want that to happen if the command to be executed
// would switch the buffer (we'd keep the already executed command in
// the old buffer and delete any input restored from the new buffer)
strv_append_owned (&ctx->pending_input, line);
poller_idle_set (&ctx->input_event);
}
- else
+ else if (isatty (STDIN_FILENO))
{
- // Prevent readline from showing the prompt twice for w/e reason
+ // Prevent Readline from showing the prompt twice for w/e reason
CALL (ctx->input, hide);
input_rl__restore (self);
CALL (ctx->input, ding);
}
+ else
+ request_quit (ctx, NULL);
if (self->active)
// Readline automatically redisplays it
@@ -15715,28 +15724,31 @@ client_process_message (struct client *c,
return true;
}
+ bool acknowledge = true;
switch (m->data.command)
{
case RELAY_COMMAND_HELLO:
+ c->initialized = true;
if (m->data.hello.version != RELAY_VERSION)
{
- // TODO: This should send back an error message and shut down.
log_global_error (c->ctx,
"Protocol version mismatch, killing client");
- return false;
+ relay_prepare_error (c->ctx,
+ m->command_seq, "Protocol version mismatch");
+ relay_send (c);
+
+ c->closing = true;
+ return true;
}
- c->initialized = true;
client_resync (c);
break;
case RELAY_COMMAND_PING:
- relay_prepare_response (c->ctx, m->command_seq)
- ->data.command = RELAY_COMMAND_PING;
- relay_send (c);
break;
case RELAY_COMMAND_ACTIVE:
reset_autoaway (c->ctx);
break;
case RELAY_COMMAND_BUFFER_COMPLETE:
+ acknowledge = false;
client_process_buffer_complete (c, m->command_seq, buffer,
&m->data.buffer_complete);
break;
@@ -15750,13 +15762,21 @@ client_process_message (struct client *c,
buffer_toggle_unimportant (c->ctx, buffer);
break;
case RELAY_COMMAND_BUFFER_LOG:
+ acknowledge = false;
client_process_buffer_log (c, m->command_seq, buffer);
break;
default:
+ acknowledge = false;
log_global_debug (c->ctx, "Unhandled client command");
relay_prepare_error (c->ctx, m->command_seq, "Unknown command");
relay_send (c);
}
+ if (acknowledge)
+ {
+ relay_prepare_response (c->ctx, m->command_seq)
+ ->data.command = m->data.command;
+ relay_send (c);
+ }
return true;
}
@@ -15778,7 +15798,7 @@ client_process_buffer (struct client *c)
break;
struct relay_command_message m = {};
- bool ok = client_process_message (c, &r, &m);
+ bool ok = c->closing || client_process_message (c, &r, &m);
relay_command_message_free (&m);
if (!ok)
return false;
@@ -15850,7 +15870,11 @@ on_client_ready (const struct pollfd *pfd, void *user_data)
{
struct client *c = user_data;
if (client_try_read (c) && client_try_write (c))
+ {
client_update_poller (c, pfd);
+ if (c->closing && !c->write_buffer.len)
+ client_kill (c);
+ }
}
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
diff --git a/xC.lxdr b/xC.lxdr
index af0f170..eba914f 100644
--- a/xC.lxdr
+++ b/xC.lxdr
@@ -1,7 +1,8 @@
// Backwards-compatible protocol version.
-const VERSION = 1;
+const VERSION = 2;
// From the frontend to the relay.
+// All commands receive either an Event.RESPONSE, or an Event.ERROR.
struct CommandMessage {
// The command sequence number will be repeated in responses
// in the respective fields.
@@ -32,13 +33,10 @@ struct CommandMessage {
// XXX: Perhaps this should rather be handled through a /buffer command.
case BUFFER_TOGGLE_UNIMPORTANT:
string buffer_name;
- case PING_RESPONSE:
- u32 event_seq;
-
- // Only these commands may produce Event.RESPONSE, as below,
- // but any command may produce an error.
case PING:
void;
+ case PING_RESPONSE:
+ u32 event_seq;
case BUFFER_COMPLETE:
string buffer_name;
string text;
@@ -52,6 +50,9 @@ struct CommandMessage {
struct EventMessage {
u32 event_seq;
union EventData switch (enum Event {
+ ERROR,
+ RESPONSE,
+
PING,
BUFFER_LINE,
BUFFER_UPDATE,
@@ -64,12 +65,28 @@ struct EventMessage {
SERVER_UPDATE,
SERVER_RENAME,
SERVER_REMOVE,
- ERROR,
- RESPONSE,
} event) {
+ // Restriction: command_seq strictly follows the sequence received
+ // by the relay, across both of these replies.
+ case ERROR:
+ u32 command_seq;
+ string error;
+ case RESPONSE:
+ u32 command_seq;
+ union ResponseData switch (Command command) {
+ case BUFFER_COMPLETE:
+ u32 start;
+ string completions<>;
+ case BUFFER_LOG:
+ // UTF-8, but not guaranteed.
+ u8 log<>;
+ default:
+ // Reception acknowledged.
+ void;
+ } data;
+
case PING:
void;
-
case BUFFER_LINE:
string buffer_name;
// Whether the line should also be displayed in the active buffer.
@@ -188,23 +205,5 @@ struct EventMessage {
string new;
case SERVER_REMOVE:
string server_name;
-
- // Restriction: command_seq strictly follows the sequence received
- // by the relay, across both of these replies.
- case ERROR:
- u32 command_seq;
- string error;
- case RESPONSE:
- u32 command_seq;
- union ResponseData switch (Command command) {
- case PING:
- void;
- case BUFFER_COMPLETE:
- u32 start;
- string completions<>;
- case BUFFER_LOG:
- // UTF-8, but not guaranteed.
- u8 log<>;
- } data;
} data;
};
diff --git a/xK-version b/xK-version
index 227cea2..7ec1d6d 100644
--- a/xK-version
+++ b/xK-version
@@ -1 +1 @@
-2.0.0
+2.1.0
diff --git a/xM/gen-icon.swift b/xM/gen-icon.swift
index 712e9e6..0f7477c 100644
--- a/xM/gen-icon.swift
+++ b/xM/gen-icon.swift
@@ -3,6 +3,9 @@
// Copyright (c) 2023, Přemysl Eric Janouch <p@janouch.name>
// SPDX-License-Identifier: 0BSD
//
+// As an odd regression, AppKit may be necessary for JIT linking.
+import AppKit
+
// NSGraphicsContext mostly just weirdly wraps over Quartz,
// so we do it all in Quartz directly.
import CoreGraphics
diff --git a/xM/main.swift b/xM/main.swift
index 91e3499..39b66c5 100644
--- a/xM/main.swift
+++ b/xM/main.swift
@@ -173,8 +173,11 @@ class RelayRPC {
func send(data: RelayCommandData, callback: Callback? = nil) {
self.commandSeq += 1
let m = RelayCommandMessage(commandSeq: self.commandSeq, data: data)
- if let callback = callback {
- self.commandCallbacks[m.commandSeq] = callback
+ self.commandCallbacks[m.commandSeq] = callback ?? { error, data in
+ if data == nil {
+ NSSound.beep()
+ Logger().warning("\(error)")
+ }
}
var w = RelayWriter()
@@ -842,11 +845,11 @@ relayRPC.onEvent = { message in
b.bufferName = data.new
- refreshBufferList()
if b.bufferName == relayBufferCurrent {
relayBufferCurrent = data.new
refreshStatus()
}
+ refreshBufferList()
if b.bufferName == relayBufferLast {
relayBufferLast = data.new
}
@@ -1203,6 +1206,7 @@ class WindowDelegate: NSObject, NSWindowDelegate {
b.highlighted = false
refreshIcon()
+ refreshBufferList()
}
// Buffer indexes rotated to start after the current buffer.
diff --git a/xN/xN.go b/xN/xN.go
index bdec3dd..20f36c7 100644
--- a/xN/xN.go
+++ b/xN/xN.go
@@ -247,16 +247,16 @@ func main() {
flag.PrintDefaults()
}
flag.Parse()
- if flag.NArg() < 1 {
- flag.Usage()
- os.Exit(2)
- }
-
if *version {
fmt.Printf("%s %s\n", projectName, projectVersion)
return
}
+ if flag.NArg() < 1 {
+ flag.Usage()
+ os.Exit(2)
+ }
+
text, err := io.ReadAll(os.Stdin)
if err != nil {
log.Fatalln(err)
diff --git a/xP/go.mod b/xP/go.mod
index 70cc10d..dca4d10 100644
--- a/xP/go.mod
+++ b/xP/go.mod
@@ -1,10 +1,7 @@
module janouch.name/xK/xP
-go 1.18
+go 1.21
-require nhooyr.io/websocket v1.8.7
+toolchain go1.23.2
-require (
- github.com/klauspost/compress v1.15.9 // indirect
- golang.org/x/sys v0.0.0-20210423082822-04245dca01da // indirect
-)
+require nhooyr.io/websocket v1.8.17
diff --git a/xP/go.sum b/xP/go.sum
index c94a673..9c3072b 100644
--- a/xP/go.sum
+++ b/xP/go.sum
@@ -1,62 +1,2 @@
-github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
-github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
-github.com/gin-gonic/gin v1.6.3 h1:ahKqKTFpO5KTPHxWZjEdPScmYaGtLo8Y4DMHoEsnp14=
-github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M=
-github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
-github.com/go-playground/locales v0.13.0 h1:HyWk6mgj5qFqCT5fjGBuRArbVDfE4hi8+e8ceBS/t7Q=
-github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8=
-github.com/go-playground/universal-translator v0.17.0 h1:icxd5fm+REJzpZx7ZfpaD876Lmtgy7VtROAbHHXk8no=
-github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA=
-github.com/go-playground/validator/v10 v10.2.0 h1:KgJ0snyC2R9VXYN2rneOtQcw5aHQB1Vv0sFl1UcHBOY=
-github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GOhaH6EGOAJShg8Id5JGkI=
-github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee h1:s+21KNqlpePfkah2I+gwHF8xmJWRjooY+5248k6m4A0=
-github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo=
-github.com/gobwas/pool v0.2.0 h1:QEmUOlnSjWtnpRGHF3SauEiOsy82Cup83Vf2LcMlnc8=
-github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
-github.com/gobwas/ws v1.0.2 h1:CoAavW/wd/kulfZmSIBt6p24n4j7tHgNVCjsfHVNUbo=
-github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM=
-github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
-github.com/golang/protobuf v1.3.5 h1:F768QJ1E9tib+q5Sc8MkdJi1RxLTbRcTf8LJV56aRls=
-github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=
-github.com/google/go-cmp v0.4.0 h1:xsAVV57WRhGj6kEIi8ReJzQlHHqcBYCElAvkovg3B/4=
-github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
-github.com/gorilla/websocket v1.4.1 h1:q7AeDBpnBk8AogcD4DSag/Ukw/KV+YhzLj2bP5HvKCM=
-github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
-github.com/json-iterator/go v1.1.9 h1:9yzud/Ht36ygwatGx56VwCZtlI/2AD15T1X2sjSuGns=
-github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
-github.com/klauspost/compress v1.10.3/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
-github.com/klauspost/compress v1.15.9 h1:wKRjX6JRtDdrE9qwa4b/Cip7ACOshUI4smpCQanqjSY=
-github.com/klauspost/compress v1.15.9/go.mod h1:PhcZ0MbTNciWF3rruxRgKxI5NkcHHrHUDtV4Yw2GlzU=
-github.com/leodido/go-urn v1.2.0 h1:hpXL4XnriNwQ/ABnpepYM/1vCLWNDfUNts8dX3xTG6Y=
-github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII=
-github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY=
-github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
-github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
-github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
-github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742 h1:Esafd1046DLDQ0W1YjYsBW+p8U2u7vzgW2SQVmlNazg=
-github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
-github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
-github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
-github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
-github.com/ugorji/go v1.1.7 h1:/68gy2h+1mWMrwZFeD1kQialdSzAb432dtpeJ42ovdo=
-github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw=
-github.com/ugorji/go/codec v1.1.7 h1:2SvQaVZ1ouYrrKKwoSk2pzd4A9evlKJb9oTL+OaLUSs=
-github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY=
-golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210423082822-04245dca01da h1:b3NXsE2LusjYGGjL5bxEVZZORm/YEFFrWFjR8eFrw/c=
-golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
-golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
-golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
-golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
-golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
-gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-nhooyr.io/websocket v1.8.7 h1:usjR2uOr/zjjkVMy0lW+PPohFok7PCow5sDjLgX4P4g=
-nhooyr.io/websocket v1.8.7/go.mod h1:B70DZP8IakI65RVQ51MsWP/8jndNma26DVA/nFSCgW0=
+nhooyr.io/websocket v1.8.17 h1:KEVeLJkUywCKVsnLIDlD/5gtayKp8VoCkksHCGGfT9Y=
+nhooyr.io/websocket v1.8.17/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c=
diff --git a/xP/public/xP.js b/xP/public/xP.js
index 5436a65..33d7d2a 100644
--- a/xP/public/xP.js
+++ b/xP/public/xP.js
@@ -1,4 +1,4 @@
-// Copyright (c) 2022 - 2023, Přemysl Eric Janouch <p@janouch.name>
+// Copyright (c) 2022 - 2025, Přemysl Eric Janouch <p@janouch.name>
// SPDX-License-Identifier: 0BSD
import * as Relay from './proto.js'
@@ -67,18 +67,19 @@ class RelayRPC extends EventTarget {
_processOne(message) {
let e = message.data
+ let p
switch (e.event) {
case Relay.Event.Error:
- if (this.promised[e.commandSeq] !== undefined)
- this.promised[e.commandSeq].reject(e.error)
- else
+ if ((p = this.promised[e.commandSeq]) === undefined)
console.error(`Unawaited error: ${e.error}`)
+ else if (p !== true)
+ p.reject(e.error)
break
case Relay.Event.Response:
- if (this.promised[e.commandSeq] !== undefined)
- this.promised[e.commandSeq].resolve(e.data)
- else
+ if ((p = this.promised[e.commandSeq]) === undefined)
console.error("Unawaited response")
+ else if (p !== true)
+ p.resolve(e.data)
break
default:
e.eventSeq = message.eventSeq
@@ -95,6 +96,13 @@ class RelayRPC extends EventTarget {
this.promised[seq].reject("No response")
delete this.promised[seq]
}
+ m.redraw()
+ }
+
+ get busy() {
+ for (const seq in this.promised)
+ return true
+ return false
}
send(params) {
@@ -110,6 +118,9 @@ class RelayRPC extends EventTarget {
this.ws.send(JSON.stringify({commandSeq: seq, data: params}))
+ this.promised[seq] = true
+ m.redraw()
+
// Automagically detect if we want a result.
let data = undefined
const promise = new Promise(
@@ -191,6 +202,17 @@ let bufferAutoscroll = true
let servers = new Map()
+let lastActive = undefined
+
+function notifyActive() {
+ // Reduce unnecessary traffic.
+ const now = Date.now()
+ if (lastActive === undefined || (now - lastActive >= 5000)) {
+ lastActive = now
+ rpc.send({command: 'Active'})
+ }
+}
+
function bufferResetStats(b) {
b.newMessages = 0
b.newUnimportantMessages = 0
@@ -998,7 +1020,7 @@ let Input = {
onKeyDown: event => {
// TODO: And perhaps on other actions, too.
- rpc.send({command: 'Active'})
+ notifyActive()
let b = buffers.get(bufferCurrent)
if (b === undefined || event.isComposing)
@@ -1103,7 +1125,13 @@ let Main = {
return m('.xP', {}, [
overlay,
- m('.title', {}, [m('b', {}, `xP`), m(Topic)]),
+ m('.title', {}, [
+ m('span', [
+ rpc.busy ? '⋯ ' : undefined,
+ m('b', {}, `xP`),
+ ]),
+ m(Topic),
+ ]),
m('.middle', {}, [m(BufferList), m(BufferContainer)]),
m(Status),
m('.input', {}, [m(Prompt), m(Input)]),
diff --git a/xP/xP.go b/xP/xP.go
index ba63fe9..188fd5d 100644
--- a/xP/xP.go
+++ b/xP/xP.go
@@ -75,12 +75,12 @@ func relayMakeReceiver(ctx context.Context, conn net.Conn) <-chan []byte {
go func() {
defer close(p)
for {
- j := relayReadFrame(r)
- if j == nil {
+ b := relayReadFrame(r)
+ if b == nil {
return
}
select {
- case p <- j:
+ case p <- b:
case <-ctx.Done():
return
}
@@ -145,8 +145,7 @@ func clientWriteError(ctx context.Context, ws *websocket.Conn, err error) bool {
b, ok := (&RelayEventMessage{
EventSeq: 0,
Data: RelayEventData{
- Interface: RelayEventDataError{
- Event: RelayEventError,
+ Variant: &RelayEventDataError{
CommandSeq: 0,
Error: err.Error(),
},
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/xS/Makefile b/xS/Makefile
index 57927fa..770be13 100644
--- a/xS/Makefile
+++ b/xS/Makefile
@@ -5,7 +5,7 @@ AWK = env LC_ALL=C awk
outputs = xS xS-replies.go xS.1
all: $(outputs)
-xS: xS.go ../xK-version xS-replies.go
+xS: xS.go xS-replies.go ../xK-version
go build -ldflags "-X 'main.projectVersion=$$(cat ../xK-version)'" -o $@
xS-replies.go: xS-gen-replies.awk xS-replies
$(AWK) -f xS-gen-replies.awk xS-replies > $@
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) {