aboutsummaryrefslogtreecommitdiff
path: root/xA
diff options
context:
space:
mode:
Diffstat (limited to 'xA')
-rw-r--r--xA/.gitignore1
-rw-r--r--xA/Makefile13
-rw-r--r--xA/go.mod10
-rw-r--r--xA/go.sum8
-rw-r--r--xA/xA.go678
5 files changed, 581 insertions, 129 deletions
diff --git a/xA/.gitignore b/xA/.gitignore
index 34289f1..5e6a147 100644
--- a/xA/.gitignore
+++ b/xA/.gitignore
@@ -2,3 +2,4 @@
/proto.go
/FyneApp.toml
/*.png
+/beep.raw
diff --git a/xA/Makefile b/xA/Makefile
index b597652..d0f0449 100644
--- a/xA/Makefile
+++ b/xA/Makefile
@@ -4,8 +4,10 @@
AWK = env LC_ALL=C awk
tools = ../liberty/tools
-outputs = FyneApp.toml xA proto.go xA.png xA-highlighted.png
+generated = FyneApp.toml xA.png xA-highlighted.png beep.raw proto.go
+outputs = xA $(generated)
all: $(outputs)
+generate: $(generated)
FyneApp.toml: ../xK-version
printf "\
@@ -21,11 +23,14 @@ FyneApp.toml: ../xK-version
Categories = ['Network', 'Chat', 'IRCClient']\n" > $@
.svg.png:
rsvg-convert --output=$@ -- $<
-xA: xA.go proto.go ../xK-version xA.png xA-highlighted.png
- go build -ldflags "-X 'main.projectVersion=$$(cat ../xK-version)'" -o $@ \
- -gcflags=all="-N -l"
+beep.raw:
+ sox -Dr 44100 -c 1 -e signed-integer -b 16 -L -n $@ \
+ synth 0.1 0 25 triangle 800 vol 0.5 fade t 0 -0 0.005 pad 0 0.05
proto.go: $(tools)/lxdrgen.awk $(tools)/lxdrgen-go.awk ../xC.lxdr
$(AWK) -f $(tools)/lxdrgen.awk -f $(tools)/lxdrgen-go.awk \
-v PrefixCamel=Relay ../xC.lxdr > $@
+xA: xA.go ../xK-version $(generated)
+ go build -ldflags "-X 'main.projectVersion=$$(cat ../xK-version)'" -o $@ \
+ -gcflags=all="-N -l"
clean:
rm -f $(outputs)
diff --git a/xA/go.mod b/xA/go.mod
index 40238ea..b758d9e 100644
--- a/xA/go.mod
+++ b/xA/go.mod
@@ -1,13 +1,17 @@
module janouch.name/xK/xA
-go 1.23
+go 1.22
-require fyne.io/fyne/v2 v2.5.2
+require (
+ fyne.io/fyne/v2 v2.5.2
+ github.com/ebitengine/oto/v3 v3.3.1
+)
require (
fyne.io/systray v1.11.0 // indirect
github.com/BurntSushi/toml v1.4.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
+ github.com/ebitengine/purego v0.8.1 // indirect
github.com/fredbi/uri v1.1.0 // indirect
github.com/fsnotify/fsnotify v1.8.0 // indirect
github.com/fyne-io/gl-js v0.0.0-20230506162202-1fdaa286a934 // indirect
@@ -23,7 +27,7 @@ require (
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect
github.com/nicksnyder/go-i18n/v2 v2.4.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
- github.com/rymdport/portal v0.2.6 // indirect
+ github.com/rymdport/portal v0.3.0 // indirect
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect
github.com/stretchr/testify v1.9.0 // indirect
diff --git a/xA/go.sum b/xA/go.sum
index 6a6d9f8..7cdb3c6 100644
--- a/xA/go.sum
+++ b/xA/go.sum
@@ -65,6 +65,10 @@ github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsr
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/ebitengine/oto/v3 v3.3.1 h1:d4McwGQuXOT0GL7bA5g9ZnaUEIEjQvG3hafzMy+T3qE=
+github.com/ebitengine/oto/v3 v3.3.1/go.mod h1:MZeb/lwoC4DCOdiTIxYezrURTw7EvK/yF863+tmBI+U=
+github.com/ebitengine/purego v0.8.1 h1:sdRKd6plj7KYW33EH5As6YKfe8m9zbN9JMrOjNVF/BE=
+github.com/ebitengine/purego v0.8.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
@@ -248,8 +252,8 @@ github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6L
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
-github.com/rymdport/portal v0.2.6 h1:HWmU3gORu7vWcpr7VSwUS2Xx1HtJXVcUuTqEZcMEsIg=
-github.com/rymdport/portal v0.2.6/go.mod h1:kFF4jslnJ8pD5uCi17brj/ODlfIidOxlgUDTO5ncnC4=
+github.com/rymdport/portal v0.3.0 h1:QRHcwKwx3kY5JTQcsVhmhC3TGqGQb9LFghVNUy8AdB8=
+github.com/rymdport/portal v0.3.0/go.mod h1:kFF4jslnJ8pD5uCi17brj/ODlfIidOxlgUDTO5ncnC4=
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
github.com/shurcooL/go v0.0.0-20200502201357-93f07166e636/go.mod h1:TDJrrUr11Vxrven61rcy3hJMUqaf/CLWYhHNPmT14Lk=
github.com/shurcooL/httpfs v0.0.0-20190707220628-8d4bc4ba7749/go.mod h1:ZY1cvUeJuFPAdZ/B6v7RHavJWZn2YPVFQ1OSXhCGOkg=
diff --git a/xA/xA.go b/xA/xA.go
index f61102e..5bd3975 100644
--- a/xA/xA.go
+++ b/xA/xA.go
@@ -5,26 +5,33 @@ 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"
)
@@ -38,6 +45,8 @@ var (
iconNormal []byte
//go:embed xA-highlighted.png
iconHighlighted []byte
+ //go:embed beep.raw
+ beepSample []byte
resourceIconNormal = fyne.NewStaticResource(
"xA.png", iconNormal)
@@ -49,6 +58,16 @@ var (
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,
@@ -110,7 +129,20 @@ func (t *customTheme) Color(
return color.Black
}
- // TODO(p): Consider constants for stuff like timestamps.
+ 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
}
@@ -149,6 +181,7 @@ type bufferLineItem struct {
// XXX: Fyne's RichText doesn't support background colours.
background fyne.ThemeColorName
text string
+ link *url.URL
}
type bufferLine struct {
@@ -182,14 +215,18 @@ type buffer struct {
// Input:
- input string
- inputStart, inputEnd int
- history []string
- historyAt int
+ 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
@@ -209,25 +246,43 @@ var (
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 *customEntry
+ wEntry *inputEntry
)
// -----------------------------------------------------------------------------
+func showErrorMessage(text string) {
+ dialog.ShowError(errors.New(text), wWindow)
+}
+
func beep() {
- // TODO(p): Probably implement using https://github.com/ebitengine/oto
- // and a sample generated from the Makefile like with xW.
+ if otoContext == nil {
+ return
+ }
+ go func() {
+ <-otoReady
+ otoContext.NewPlayer(bytes.NewReader(beepSample)).Play()
+ }()
}
// --- Networking --------------------------------------------------------------
@@ -320,11 +375,52 @@ func bufferToggleUnimportant(name string) {
}, nil)
}
+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)
+ }
+}
+
// --- 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 {
- // TODO(p): Figure out how to implement this.
- return false
+ return wRichScroll.Offset.Y >=
+ wRichScroll.Content.Size().Height-wRichScroll.Size().Height
}
func bufferScrollToBottom() {
@@ -333,6 +429,7 @@ func bufferScrollToBottom() {
// to a buffer than needs scrolling.)
wRichScroll.ScrollToBottom()
wRichScroll.ScrollToBottom()
+ refreshStatus()
}
// --- UI state refresh --------------------------------------------------------
@@ -356,6 +453,11 @@ func refreshIcon() {
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{
@@ -371,9 +473,7 @@ func refreshTopic(topic []bufferLineItem) {
}
func refreshBufferList() {
- // TODO(p): See if this is enough, or even doing anything.
- // - In particular, within RelayEventDataBufferRemove handling.
- wBufferList.Refresh()
+ // This seems to be enough, even for removals.
for i := range buffers {
wBufferList.RefreshItem(widget.ListItemID(i))
}
@@ -396,12 +496,13 @@ func refreshPrompt() {
}
func refreshStatus() {
- var status string
- if !bufferAtBottom() {
- status += "🡇 "
+ if bufferAtBottom() {
+ wDown.Hide()
+ } else {
+ wDown.Show()
}
- status += bufferCurrent
+ status := bufferCurrent
if b := bufferByName(bufferCurrent); b != nil {
if b.modes != "" {
status += "(+" + b.modes + ")"
@@ -450,6 +551,39 @@ func convertItemFormatting(
}
}
+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
@@ -465,7 +599,7 @@ func convertItems(items []RelayItemData) []bufferLineItem {
if inverse {
item.color, item.background = item.background, item.color
}
- result = append(result, item)
+ result = convertLinks(item, result)
}
return result
}
@@ -525,19 +659,17 @@ func bufferPrintLine(lines []bufferLine, index int) {
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: "",
+ ColorName: colorNameBufferTimestamp,
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,
@@ -553,48 +685,48 @@ func bufferPrintLine(lines []bufferLine, index int) {
prefix = " - "
case RelayRenditionError:
prefix = "=!= "
- //pcf.ColorName = theme.ColorRed
+ pcf.ColorName = colorNameRenditionError
case RelayRenditionJoin:
prefix = "--> "
- //pcf.ColorName = theme.ColorGreen
+ pcf.ColorName = colorNameRenditionJoin
case RelayRenditionPart:
prefix = "<-- "
- //pcf.ColorName = theme.ColorRed
+ pcf.ColorName = colorNameRenditionPart
case RelayRenditionAction:
prefix = " * "
- //pcf.ColorName = theme.ColorRed
+ pcf.ColorName = colorNameRenditionAction
}
- // 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 != "" {
+ if prefix != "" {
+ style := pcf
+ if line.leaked {
+ style.ColorName = colorNameBufferLeaked
}
- for _, item := range line.items {
- _ = item
+ 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
}
- } 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,
- },
- })
+ 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,
@@ -634,7 +766,7 @@ func relayProcessBufferLine(b *buffer, m *RelayEventDataBufferLine) {
// Initial sync: skip all other processing, let highlights be.
bc := bufferByName(bufferCurrent)
if bc == nil {
- b.lines = append(b.lines, line)
+ bufferPushLine(b, line)
return
}
@@ -642,11 +774,11 @@ func relayProcessBufferLine(b *buffer, m *RelayEventDataBufferLine) {
display := (!m.IsUnimportant || !bc.hideUnimportant) &&
(b.bufferName == bufferCurrent || m.LeakToActive)
toBottom := display && bufferAtBottom()
- visible := display && toBottom && inForeground // && log not visible
+ visible := display && toBottom && inForeground && !wLog.Visible()
separate := display &&
!visible && bc.newMessages == 0 && bc.newUnimportantMessages == 0
- b.lines = append(b.lines, line)
+ bufferPushLine(b, line)
if !(visible || m.LeakToActive) ||
b.newMessages != 0 || b.newUnimportantMessages != 0 {
if line.isUnimportant || m.LeakToActive {
@@ -659,7 +791,7 @@ func relayProcessBufferLine(b *buffer, m *RelayEventDataBufferLine) {
if m.LeakToActive {
leakedLine := line
leakedLine.leaked = true
- bc.lines = append(bc.lines, leakedLine)
+ bufferPushLine(bc, leakedLine)
if !visible || bc.newMessages != 0 || bc.newUnimportantMessages != 0 {
if line.isUnimportant {
@@ -682,6 +814,7 @@ func relayProcessBufferLine(b *buffer, m *RelayEventDataBufferLine) {
}
// 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()
@@ -699,7 +832,7 @@ func relayProcessCallbacks(
commandSeq uint32, err string, response *RelayResponseData) {
if handler, ok := commandCallbacks[commandSeq]; !ok {
if *debug {
- log.Printf("unawaited response: %+v\n", *response)
+ log.Printf("Unawaited response: %+v\n", *response)
}
} else {
delete(commandCallbacks, commandSeq)
@@ -818,15 +951,21 @@ func relayProcessMessage(m *RelayEventMessage) {
old.highlighted = false
old.input = wEntry.Text
- // TODO(p): At least store Cursor{Row,Column}.
+ 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)
}
- // TODO(p): Hide the log if visible.
- b.highlighted = false
+ if wLog.Visible() {
+ bufferToggleLog()
+ }
+ if inForeground {
+ b.highlighted = false
+ }
+
for i := range buffers {
if buffers[i].bufferName == bufferCurrent {
wBufferList.Select(widget.ListItemID(i))
@@ -841,8 +980,10 @@ func relayProcessMessage(m *RelayEventMessage) {
refreshPrompt()
refreshStatus()
- // TODO(p): At least set Cursor{Row,Column}, and apply it.
wEntry.SetText(b.input)
+ wEntry.CursorRow = b.inputRow
+ wEntry.CursorColumn = b.inputColumn
+ wEntry.Refresh()
wWindow.Canvas().Focus(wEntry)
case *RelayEventDataBufferInput:
b := bufferByName(data.BufferName)
@@ -916,19 +1057,37 @@ func relayMakeReceiver(
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() {
- // TODO(p): Maybe reset state, and indicate in the UI that we're connecting.
+ fyne.CurrentApp().Preferences().SetString(preferenceAddress, backendAddress)
+ backendLock.Lock()
+ relayResetState()
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.
+ wConnect.Show()
+ showErrorMessage("Connection failed: " + err.Error())
return
}
defer backendConn.Close()
@@ -941,23 +1100,27 @@ func relayRun() {
}, nil)
relayMessages := relayMakeReceiver(backendContext, backendConn)
+Loop:
for {
select {
case m, ok := <-relayMessages:
if !ok {
- break
+ break Loop
}
relayProcessMessage(&m)
- default:
- break
}
}
- // TODO(p): Indicate in the UI that we're no longer connected.
+ wConnect.Show()
+ showErrorMessage("Disconnected")
}
// --- Input line --------------------------------------------------------------
+func inputSetContents(input string) {
+ wEntry.SetText(input)
+}
+
func inputSubmit(text string) bool {
b := bufferByName(bufferCurrent)
if b == nil {
@@ -966,7 +1129,7 @@ func inputSubmit(text string) bool {
b.history = append(b.history, text)
b.historyAt = len(b.history)
- wEntry.SetText("")
+ inputSetContents("")
relaySend(RelayCommandData{Variant: &RelayCommandDataBufferInput{
BufferName: b.bufferName,
@@ -975,67 +1138,156 @@ func inputSubmit(text string) bool {
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 customEntry struct {
+type inputEntry 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{}
+func newInputEntry() *inputEntry {
+ e := &inputEntry{}
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)
+func (e *inputEntry) FocusLost() {
e.selectKeyDown = false
e.Entry.FocusLost()
}
-func (e *customEntry) KeyDown(key *fyne.KeyEvent) {
- e.down[key.Name] = true
+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 *customEntry) KeyUp(key *fyne.KeyEvent) {
- delete(e.down, key.Name)
+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 *customEntry) TypedKey(key *fyne.KeyEvent) {
+func (e *inputEntry) 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}
@@ -1049,15 +1301,170 @@ func (e *customEntry) TypedKey(key *fyne.KeyEvent) {
e.OnSubmitted(e.Text)
}
case fyne.KeyTab:
- // TODO(p): Just do e.Append() if state matches.
- log.Println("completion")
+ 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()
+ }
+}
+
+// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+
+// 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(),
@@ -1071,6 +1478,16 @@ func main() {
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)
@@ -1119,60 +1536,81 @@ func main() {
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 = 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)
- }
+ 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, wStatus),
+ 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))
+ 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
}
- connectForm := dialog.NewForm("Connect to relay", "Connect", "Exit",
+
+ // 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 {
- a.Quit()
- } else {
+ 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 flag.NArg() >= 1 {
- backendAddress = flag.Arg(0)
- connectAddress.SetText(backendAddress)
+ if connect {
go relayRun()
} else {
- connectForm.Show()
+ wConnect.Show()
}
wWindow.ShowAndRun()