2024-09-14 07:32:44 +02:00
|
|
|
// 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)
|
|
|
|
}
|
|
|
|
|
2024-11-12 16:19:44 +01:00
|
|
|
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)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-09-14 07:32:44 +02:00
|
|
|
// --- 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 {
|
2024-11-12 16:19:44 +01:00
|
|
|
bufferPushLine(b, line)
|
2024-09-14 07:32:44 +02:00
|
|
|
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
|
|
|
|
|
2024-11-12 16:19:44 +01:00
|
|
|
bufferPushLine(b, line)
|
2024-09-14 07:32:44 +02:00
|
|
|
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
|
2024-11-12 16:19:44 +01:00
|
|
|
bufferPushLine(bc, leakedLine)
|
2024-09-14 07:32:44 +02:00
|
|
|
|
|
|
|
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 {
|
2024-11-13 10:28:04 +01:00
|
|
|
// XXX: Sadly, we can't seem to make it actually read-only.
|
|
|
|
// https://github.com/fyne-io/fyne/issues/5263
|
2024-09-14 07:32:44 +02:00
|
|
|
widget.Entry
|
|
|
|
}
|
|
|
|
|
|
|
|
func newLogEntry() *logEntry {
|
|
|
|
e := &logEntry{}
|
|
|
|
e.MultiLine = true
|
|
|
|
e.Wrapping = fyne.TextWrapWord
|
|
|
|
e.ExtendBaseWidget(e)
|
|
|
|
return e
|
|
|
|
}
|
|
|
|
|
2024-11-13 10:28:04 +01:00
|
|
|
func (e *logEntry) SetText(text string) {
|
|
|
|
e.OnChanged = nil
|
|
|
|
e.Entry.SetText(text)
|
|
|
|
e.OnChanged = func(string) { e.Entry.SetText(text) }
|
|
|
|
}
|
|
|
|
|
2024-09-14 07:32:44 +02:00
|
|
|
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()
|
|
|
|
}
|