diff options
author | Přemysl Eric Janouch <p@janouch.name> | 2024-09-14 07:32:44 +0200 |
---|---|---|
committer | Přemysl Eric Janouch <p@janouch.name> | 2024-11-12 12:02:10 +0100 |
commit | 1635a730e819b9e6bb2d7ca1c8047fb737e5a3f4 (patch) | |
tree | 829c2f701a0a8031c95bb65ee88957d407baec0d /xA/xA.go | |
parent | a64b1152a175a611ef879c2ceb2323f201f77dec (diff) | |
download | xK-1635a730e819b9e6bb2d7ca1c8047fb737e5a3f4.tar.gz xK-1635a730e819b9e6bb2d7ca1c8047fb737e5a3f4.tar.xz xK-1635a730e819b9e6bb2d7ca1c8047fb737e5a3f4.zip |
Add a Fyne frontend for xC
It is fairly mediocre all around, but also generally usable,
natively covering mobile platforms.
Diffstat (limited to 'xA/xA.go')
-rw-r--r-- | xA/xA.go | 1599 |
1 files changed, 1599 insertions, 0 deletions
diff --git a/xA/xA.go b/xA/xA.go new file mode 100644 index 0000000..e5f5ce8 --- /dev/null +++ b/xA/xA.go @@ -0,0 +1,1599 @@ +// 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 + otoContext.NewPlayer(bytes.NewReader(beepSample)).Play() + }() +} + +// --- 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 bufferActivate(name string) { + relaySend(RelayCommandData{ + Variant: &RelayCommandDataBufferActivate{BufferName: name}, + }, nil) +} + +func bufferToggleUnimportant(name string) { + relaySend(RelayCommandData{ + Variant: &RelayCommandDataBufferToggleUnimportant{BufferName: name}, + }, nil) +} + +// --- Current buffer ---------------------------------------------------------- + +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("") + return + } + + name := bufferCurrent + relaySend(RelayCommandData{Variant: &RelayCommandDataBufferLog{ + BufferName: name, + }}, func(err string, response *RelayResponseData) { + if bufferCurrent == name { + bufferToggleLogFinish( + err, response.Variant.(*RelayResponseDataBufferLog)) + } + }) +} + +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() +} + +// --- UI state refresh -------------------------------------------------------- + +func refreshIcon() { + highlighted := false + for _, b := range buffers { + if b.highlighted { + highlighted = true + break + } + } + + if highlighted { + wWindow.SetIcon(resourceIconHighlighted) + } else { + wWindow.SetIcon(resourceIconNormal) + } +} + +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) +} + +// --- 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() +} + +// --- 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 { + b.lines = append(b.lines, 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 + + b.lines = append(b.lines, 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 + bc.lines = append(bc.lines, 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 + + 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: + 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 read-only in any way. + widget.Entry +} + +func newLogEntry() *logEntry { + e := &logEntry{} + e.MultiLine = true + e.Wrapping = fyne.TextWrapWord + e.ExtendBaseWidget(e) + return e +} + +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() + } +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +// 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{}) + wWindow = a.NewWindow(projectName) + wWindow.Resize(fyne.NewSize(640, 480)) + + a.Lifecycle().SetOnEnteredForeground(func() { + // TODO(p): Does this need locking? + inForeground = true + if b := bufferByName(bufferCurrent); b != nil { + b.highlighted = false + refreshIcon() + } + }) + 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) { 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() +} |