aboutsummaryrefslogtreecommitdiff
path: root/xA/xA.go
diff options
context:
space:
mode:
Diffstat (limited to 'xA/xA.go')
-rw-r--r--xA/xA.go1637
1 files changed, 1637 insertions, 0 deletions
diff --git a/xA/xA.go b/xA/xA.go
new file mode 100644
index 0000000..f501622
--- /dev/null
+++ b/xA/xA.go
@@ -0,0 +1,1637 @@
+// Copyright (c) 2024, 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
+ }
+ */
+
+ // 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 {
+ 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
+ }
+ }
+
+ // Prevent deadlocks (though it might have a race condition).
+ // https://github.com/fyne-io/fyne/issues/5266
+ go func() { 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()
+
+ relayResetState()
+ backendContext, backendCancel = context.WithCancel(context.Background())
+ defer backendCancel()
+ var err error
+ backendConn, err = net.Dial("tcp", backendAddress)
+
+ backendLock.Unlock()
+ if err != nil {
+ 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
+ }
+ relayProcessMessage(&m)
+ }
+ }
+
+ 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()
+}