// Copyright (c) 2024, Přemysl Eric Janouch
// SPDX-License-Identifier: 0BSD
package main
import (
"bufio"
"context"
_ "embed"
"encoding/binary"
"flag"
"fmt"
"image/color"
"io"
"log"
"net"
"os"
"slices"
"strings"
"sync"
"time"
"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/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
resourceIconNormal = fyne.NewStaticResource(
"xA.png", iconNormal)
resourceIconHighlighted = fyne.NewStaticResource(
"xA-highlighted.png", iconHighlighted)
)
// --- Theme -------------------------------------------------------------------
type customTheme struct{}
func convertColor(c int) color.Color {
base16 := []uint16{
0x000, 0x800, 0x080, 0x880, 0x008, 0x808, 0x088, 0xccc,
0x888, 0xf00, 0x0f0, 0xff0, 0x00f, 0xf0f, 0x0ff, 0xfff,
}
if c < 16 {
r := 0xf & uint8(base16[c]>>8)
g := 0xf & uint8(base16[c]>>4)
b := 0xf & uint8(base16[c])
return color.RGBA{r * 0x11, g * 0x11, b * 0x11, 0xff}
}
if c >= 216 {
return color.Gray{8 + uint8(c-216)*10}
}
var (
i = uint8(c - 16)
r = i / 36 >> 0
g = (i / 6 >> 0) % 6
b = i % 6
)
if r != 0 {
r = 55 + 40*r
}
if g != 0 {
g = 55 + 40*g
}
if b != 0 {
b = 55 + 40*b
}
return color.RGBA{r, g, b, 0xff}
}
var ircColors = make(map[fyne.ThemeColorName]color.Color)
func ircColorName(color int) fyne.ThemeColorName {
return fyne.ThemeColorName(fmt.Sprintf("irc%02x", color))
}
func init() {
for color := 0; color < 256; color++ {
ircColors[ircColorName(color)] = convertColor(color)
}
}
func (t *customTheme) Color(
name fyne.ThemeColorName, variant fyne.ThemeVariant) color.Color {
/*
// 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
}
// TODO(p): Consider constants for stuff like timestamps.
if c, ok := ircColors[name]; ok {
return c
}
return theme.DefaultTheme().Color(name, variant)
}
func (t *customTheme) Font(style fyne.TextStyle) fyne.Resource {
return theme.DefaultTheme().Font(style)
}
func (t *customTheme) Icon(i fyne.ThemeIconName) fyne.Resource {
return theme.DefaultTheme().Icon(i)
}
func (t *customTheme) Size(s fyne.ThemeSizeName) float32 {
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
}
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
inputStart, inputEnd int
history []string
historyAt int
}
type callback func(err string, response *RelayResponseData)
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)
// Widgets:
inForeground = true
wWindow fyne.Window
wTopic *widget.RichText
wBufferList *widget.List
wRichText *widget.RichText
wRichScroll *container.Scroll
wPrompt *widget.Label
wDown *widget.Icon
wStatus *widget.Label
wEntry *customEntry
)
// -----------------------------------------------------------------------------
func beep() {
// TODO(p): Probably implement using https://github.com/ebitengine/oto
// and a sample generated from the Makefile like with xW.
}
// --- 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 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()
}
// --- 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 {
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() {
// TODO(p): See if this is enough, or even doing anything.
// - In particular, within RelayEventDataBufferRemove handling.
wBufferList.Refresh()
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 += ""
}
}
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))
}
}
}
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 = append(result, item)
}
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)
// TODO(p): Why don't the colour names work?
texts := []widget.RichTextSegment{&widget.TextSegment{
Text: line.when.Format("15:04:05 "),
Style: widget.RichTextStyle{
Alignment: fyne.TextAlignLeading,
ColorName: "",
Inline: true,
SizeName: theme.SizeNameText,
TextStyle: fyne.TextStyle{},
}}}
// Tabstops won't quite help us here, since we need it centred.
// TODO(p): Why don't the colour names work?
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 = theme.ColorRed
case RelayRenditionJoin:
prefix = "--> "
//pcf.ColorName = theme.ColorGreen
case RelayRenditionPart:
prefix = "<-- "
//pcf.ColorName = theme.ColorRed
case RelayRenditionAction:
prefix = " * "
//pcf.ColorName = theme.ColorRed
}
// TODO(p): Also scan for URLs, and transform line.items before use.
// &widget.HyperlinkSegment{Text: "X", URL: testURL},
// TODO(p): Render timestamps as well.
if line.leaked {
// TODO(p): Similar as below, but everything greyed out.
if prefix != "" {
}
for _, item := range line.items {
_ = item
}
} else {
if prefix != "" {
texts = append(texts, &widget.TextSegment{
Text: prefix,
Style: pcf,
})
}
for _, item := range line.items {
texts = append(texts, &widget.TextSegment{
Text: item.text,
Style: widget.RichTextStyle{
Alignment: fyne.TextAlignLeading,
ColorName: item.color,
Inline: true,
SizeName: theme.SizeNameText,
TextStyle: item.format,
},
})
}
}
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 // && log not 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.
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
// TODO(p): At least store Cursor{Row,Column}.
// Note that we effectively overwrite the newest line
// with the current textarea contents, and jump there.
old.historyAt = len(old.history)
}
// TODO(p): Hide the log if visible.
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()
// TODO(p): At least set Cursor{Row,Column}, and apply it.
wEntry.SetText(b.input)
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 relayRun() {
// TODO(p): Maybe reset state, and indicate in the UI that we're connecting.
backendContext, backendCancel = context.WithCancel(context.Background())
defer backendCancel()
var err error
backendLock.Lock()
backendConn, err = net.Dial("tcp", backendAddress)
backendLock.Unlock()
if err != nil {
log.Println("Connection failed: " + err.Error())
// TODO(p): Display errors to the user.
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)
for {
select {
case m, ok := <-relayMessages:
if !ok {
break
}
relayProcessMessage(&m)
default:
break
}
}
// TODO(p): Indicate in the UI that we're no longer connected.
}
// --- Input line --------------------------------------------------------------
func inputSubmit(text string) bool {
b := bufferByName(bufferCurrent)
if b == nil {
return false
}
b.history = append(b.history, text)
b.historyAt = len(b.history)
wEntry.SetText("")
relaySend(RelayCommandData{Variant: &RelayCommandDataBufferInput{
BufferName: b.bufferName,
Text: text,
}}, nil)
return true
}
// --- General UI --------------------------------------------------------------
type customEntry struct {
widget.Entry
// selectKeyDown is a hack to exactly invert widget.Entry's behaviour,
// which groups both Shift keys together.
selectKeyDown bool
down map[fyne.KeyName]bool
}
func newCustomEntry() *customEntry {
e := &customEntry{}
e.MultiLine = true
e.Wrapping = fyne.TextWrap(fyne.TextTruncateClip)
e.down = make(map[fyne.KeyName]bool)
e.ExtendBaseWidget(e)
return e
}
func (e *customEntry) FocusLost() {
e.down = make(map[fyne.KeyName]bool)
e.selectKeyDown = false
e.Entry.FocusLost()
}
func (e *customEntry) KeyDown(key *fyne.KeyEvent) {
e.down[key.Name] = true
if key.Name == desktop.KeyShiftLeft || key.Name == desktop.KeyShiftRight {
e.selectKeyDown = true
}
e.Entry.KeyDown(key)
}
func (e *customEntry) KeyUp(key *fyne.KeyEvent) {
delete(e.down, key.Name)
if key.Name == desktop.KeyShiftLeft || key.Name == desktop.KeyShiftRight {
e.selectKeyDown = false
}
e.Entry.KeyUp(key)
}
func (e *customEntry) TypedKey(key *fyne.KeyEvent) {
if e.Disabled() {
return
}
/*
modified := false
for _, name := range []fyne.KeyName{
desktop.KeyAltLeft, desktop.KeyAltRight,
desktop.KeyControlLeft, desktop.KeyControlRight,
desktop.KeySuperLeft, desktop.KeySuperRight,
} {
if e.down[name] {
modified = true
}
}
*/
// 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:
// TODO(p): Just do e.Append() if state matches.
log.Println("completion")
default:
e.Entry.TypedKey(key)
}
}
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
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)
}
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()
}
wPrompt = widget.NewLabelWithStyle(
"", fyne.TextAlignLeading, fyne.TextStyle{Bold: true})
wStatus = widget.NewLabelWithStyle(
"", fyne.TextAlignTrailing, fyne.TextStyle{})
wEntry = newCustomEntry()
// TODO(p): Rather respond to all keypresses/similar activity.
wEntry.OnChanged = func(text string) {
relaySend(RelayCommandData{Variant: &RelayCommandDataActive{}}, nil)
}
wEntry.OnSubmitted = func(text string) {
inputSubmit(text)
}
top := container.NewVBox(
wTopic,
widget.NewSeparator(),
)
wDown = widget.NewIcon(theme.MoveDownIcon())
bottom := container.NewVBox(
widget.NewSeparator(),
container.NewBorder(nil, nil,
wPrompt, container.NewHBox(wDown, wStatus)),
wEntry,
)
split := container.NewHSplit(wBufferList, wRichScroll)
split.SetOffset(0.25)
wWindow.SetContent(container.NewBorder(top, bottom, nil, nil, split))
connectAddress := widget.NewEntry()
connectAddress.SetPlaceHolder("host:port")
connectAddress.Validator = func(text string) error {
_, _, err := net.SplitHostPort(text)
return err
}
connectForm := dialog.NewForm("Connect to relay", "Connect", "Exit",
[]*widget.FormItem{
{Text: "Address:", Widget: connectAddress},
}, func(ok bool) {
if !ok {
a.Quit()
} else {
backendAddress = connectAddress.Text
go relayRun()
}
}, wWindow)
if flag.NArg() >= 1 {
backendAddress = flag.Arg(0)
connectAddress.SetText(backendAddress)
go relayRun()
} else {
connectForm.Show()
}
wWindow.ShowAndRun()
}