// 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" "slices" "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 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 { // Fuck this low contrast shit, text must be black. if name == theme.ColorNameForeground && variant == theme.VariantLight { return color.Black } // TODO(p): Consider constants for stuff like timestamps. 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 { 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. // XXX: Fyne's RichText doesn't support background colours. color fyne.ThemeColorName 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 string, response *RelayResponseData) var ( backendAddress string backendContext context.Context backendCancel context.CancelFunc // Connection state: commandSeq uint32 commandCallbacks = make(map[uint32]callback) buffers []buffer bufferCurrent string bufferLast string servers = make(map[string]*server) // Widgets: wRichText *widget.RichText wRichScroll *container.Scroll wEntry *widget.Entry ) // ----------------------------------------------------------------------------- func refreshBuffer(b *buffer) { // TODO(p): See xW, rewrite the whole buffer view. } func refreshBufferList() { // TODO(p): First off, add a buffer list, second, refresh it. } func refreshTopic(topic []bufferLineItem) { // TODO(p): First off, add a topic, second, refresh it. } func refreshStatus() { // TODO(p): First off, add a status, second, refresh it. } func refreshIcon() { // TODO(p): Can we have an icon at all? } func refreshPrompt() { // TODO(p): First off, add a prompt, second, refresh it. } 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) } return true } func relayProcessCallbacks( commandSeq uint32, err string, response *RelayResponseData) { if handler, ok := commandCallbacks[commandSeq]; !ok { // TODO(p): Warn about an unawaited 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 bufferByName(name string) *buffer { for i := range buffers { if buffers[i].bufferName == name { return &buffers[i] } } return 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: // TODO(p): Send the command. _ = RelayCommandData{ Variant: &RelayCommandDataPingResponse{EventSeq: m.EventSeq}, } case *RelayEventDataBufferLine: // TODO(p): Process all remaining message kinds. case *RelayEventDataBufferUpdate: b := bufferByName(data.BufferName) if b == nil { buffers = append(buffers, buffer{}) 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 // TODO(p): Convert the items. b.topic = nil case *RelayBufferContextPrivateMessage: b.serverName = context.ServerName } // TODO: Port over the rest as well. 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 refreshBufferList() if data.BufferName == bufferCurrent { bufferCurrent = data.New refreshStatus() } 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: // TODO(p): Process all remaining message kinds. 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) } } 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, RelayCommandData{ Variant: &RelayCommandDataHello{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() }