// Copyright (c) 2024, Přemysl Eric Janouch
// SPDX-License-Identifier: 0BSD package main import ( "bufio" "context" "encoding/binary" "flag" "fmt" "image/color" "io" "log" "net" "net/url" "os" "time" "fyne.io/fyne/v2" "fyne.io/fyne/v2/app" "fyne.io/fyne/v2/container" "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/widget" ) var ( debug = flag.Bool("debug", false, "enable debug output") projectName = "xA" projectVersion = "?" ) // --- Theme ------------------------------------------------------------------- type customTheme struct{} func (t *customTheme) Color( name fyne.ThemeColorName, variant fyne.ThemeVariant) color.Color { if name == theme.ColorNameForeground && variant == theme.VariantLight { return color.Black } 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 { return theme.DefaultTheme().Size(s) } // --- Relay state ------------------------------------------------------------- type server struct { state RelayServerState user string userModes string } type bufferLineItem struct { // TODO(p): Figure out how to store formatting. // → fyne.TextStyle, however this doesn't store bg or fg colour, // which is only a widget.{Custom,}TextGridStyle thing. // - I'll likely have to create 257^2 name combinations, // plus perhaps add names for "internal" stuff like timestamps. // - theme.ColorForWidget text string } 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 // TODO(p): Server by name or by pointer? // Channel: topic []bufferLineItem modes string // Stats: newMessages int newUnimportantMessages int highlighted bool // Input: input string inputStart, inputEnd int history []string historyAt int } type callback func(err error, response *RelayResponseData) var ( backendAddress string backendContext context.Context backendCancel context.CancelFunc // Connection state: commandSeq uint32 commandCallbacks map[uint32]callback buffers []buffer bufferCurrent string bufferLast string servers map[string]server // Widgets: wRichText *widget.RichText wRichScroll *container.Scroll wEntry *widget.Entry ) // ----------------------------------------------------------------------------- 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 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 relayWriteMessage(conn net.Conn, commandData any) bool { m := RelayCommandMessage{ CommandSeq: commandSeq, Data: RelayCommandData{commandData}, } commandSeq++ 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 := conn.Write(b); err != nil { log.Println("Command send failed: " + err.Error()) return false } if *debug { log.Printf("-> %v\n", b) } return true } func relayProcessMessage(m *RelayEventMessage) { switch data := m.Data.Interface.(type) { case RelayEventDataError: // TODO(p): Process callbacks. _ = data.CommandSeq _ = data.Error case RelayEventDataResponse: // TODO(p): Process callbacks. _ = data.CommandSeq _ = data.Data case RelayEventDataPing: // TODO(p): Send the command. _ = RelayCommandDataPingResponse{ Command: RelayCommandPingResponse, EventSeq: m.EventSeq, } // TODO(p): Process all remaining message kinds. case RelayEventDataBufferLine: case RelayEventDataBufferUpdate: case RelayEventDataBufferStats: case RelayEventDataBufferRename: case RelayEventDataBufferRemove: case RelayEventDataBufferActivate: case RelayEventDataBufferInput: case RelayEventDataBufferClear: case RelayEventDataServerUpdate: case RelayEventDataServerRename: case RelayEventDataServerRemove: } } func relayRun() { // TODO(p): Maybe reset state, and indicate in the UI that we're connecting. backendContext, backendCancel = context.WithCancel(context.Background()) defer backendCancel() conn, err := net.Dial("tcp", backendAddress) if err != nil { log.Println("Connection failed: " + err.Error()) // TODO(p): Display errors to the user. return } defer conn.Close() // TODO(p): How to send messages? // - It would probably make the most sense to have either a chan // (which makes code synchronize), or a locked slice. // - But I also need to wake the sender up somehow, // so maybe use a channel after all. // - Or maybe use a channel just for the signalling. // - Sending (semi-)synchronously is also an option, perhaps. // TODO(p): Handle any errors here. _ = relayWriteMessage(conn, &RelayCommandDataHello{ Command: RelayCommandHello, Version: RelayVersion, }) relayMessages := relayMakeReceiver(backendContext, conn) for { select { case m, ok := <-relayMessages: if !ok { break } relayProcessMessage(&m) default: break } } // TODO(p): Indicate in the UI that we're no longer connected. } 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.NArg() > 1 { flag.Usage() os.Exit(1) } backendAddress = flag.Arg(0) a := app.New() a.Settings().SetTheme(&customTheme{}) w := a.NewWindow(projectName) // TODO(p): There should also be a widget.NewLabel() next to the entry. // - Probably another Border, even though this seems odd. wRichText = widget.NewRichText() wRichScroll = container.NewVScroll(wRichText) wEntry = widget.NewMultiLineEntry() w.SetContent(container.NewBorder(nil, wEntry, nil, nil, wRichScroll)) testURL, _ := url.Parse("https://x.com") wRichText.Segments = []widget.RichTextSegment{ &widget.ParagraphSegment{Texts: []widget.RichTextSegment{ &widget.TextSegment{Text: "Test"}, &widget.HyperlinkSegment{Text: "X", URL: testURL}, &widget.TextSegment{ Text: " is a website, certainly", Style: widget.RichTextStyleInline, }, }}, &widget.TextSegment{Style: widget.RichTextStyleParagraph}, &widget.SeparatorSegment{}, &widget.TextSegment{Text: "Paragraph"}, } wRichText.Wrapping = fyne.TextWrapWord wRichText.Refresh() go relayRun() w.ShowAndRun() }