xK/xA/xA.go
Přemysl Eric Janouch a22baa4b55
All checks were successful
Alpine 3.20 Success
Arch Linux AUR Success
OpenBSD 7.5 Success
xA: prevent sound playback GC
The beep sound could be cut short.
2024-11-14 16:48:44 +01:00

1638 lines
37 KiB
Go

// 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()
}