diff options
Diffstat (limited to 'xA')
-rw-r--r-- | xA/.gitignore | 5 | ||||
-rw-r--r-- | xA/Makefile | 36 | ||||
-rw-r--r-- | xA/go.mod | 46 | ||||
-rw-r--r-- | xA/go.sum | 84 | ||||
-rw-r--r-- | xA/xA-highlighted.svg | 23 | ||||
-rw-r--r-- | xA/xA.go | 1651 | ||||
-rw-r--r-- | xA/xA.svg | 23 |
7 files changed, 1868 insertions, 0 deletions
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> |