aboutsummaryrefslogtreecommitdiff
path: root/xA/xA.go
diff options
context:
space:
mode:
Diffstat (limited to 'xA/xA.go')
-rw-r--r--xA/xA.go750
1 files changed, 604 insertions, 146 deletions
diff --git a/xA/xA.go b/xA/xA.go
index f61102e..f501622 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,47 @@ 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
+ p := otoContext.NewPlayer(bytes.NewReader(beepSample))
+ p.Play()
+ for p.IsPlaying() {
+ time.Sleep(time.Second)
+ }
+ }()
}
// --- Networking --------------------------------------------------------------
@@ -308,23 +367,9 @@ func bufferByName(name string) *buffer {
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 {
- // TODO(p): Figure out how to implement this.
- return false
+ return wRichScroll.Offset.Y >=
+ wRichScroll.Content.Size().Height-wRichScroll.Size().Height
}
func bufferScrollToBottom() {
@@ -333,29 +378,44 @@ func bufferScrollToBottom() {
// to a buffer than needs scrolling.)
wRichScroll.ScrollToBottom()
wRichScroll.ScrollToBottom()
+ refreshStatus()
+}
+
+func bufferPushLine(b *buffer, line bufferLine) {
+ b.lines = append(b.lines, line)
+
+ // Fyne's text layouting is extremely slow.
+ // The limit could be made configurable,
+ // and we could use a ring buffer approach to storing the lines.
+ if len(b.lines) > 100 {
+ b.lines = slices.Delete(b.lines, 0, 1)
+ }
}
// --- UI state refresh --------------------------------------------------------
func refreshIcon() {
- highlighted := false
+ resource := resourceIconNormal
for _, b := range buffers {
if b.highlighted {
- highlighted = true
+ resource = resourceIconHighlighted
break
}
}
- if highlighted {
- wWindow.SetIcon(resourceIconHighlighted)
- } else {
- wWindow.SetIcon(resourceIconNormal)
- }
+ // Prevent deadlocks (though it might have a race condition).
+ // https://github.com/fyne-io/fyne/issues/5266
+ go func() { wWindow.SetIcon(resource) }()
}
func refreshTopic(topic []bufferLineItem) {
wTopic.Segments = nil
for _, item := range topic {
+ if item.link != nil {
+ wTopic.Segments = append(wTopic.Segments,
+ &widget.HyperlinkSegment{Text: item.text, URL: item.link})
+ continue
+ }
wTopic.Segments = append(wTopic.Segments, &widget.TextSegment{
Text: item.text,
Style: widget.RichTextStyle{
@@ -371,9 +431,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 +454,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 + ")"
@@ -414,6 +473,63 @@ func refreshStatus() {
wStatus.SetText(status)
}
+func recheckHighlighted() {
+ // Corresponds to the logic toggling the bool on.
+ if b := bufferByName(bufferCurrent); b != nil &&
+ b.highlighted && bufferAtBottom() &&
+ inForeground && !wLog.Visible() {
+ b.highlighted = false
+ refreshIcon()
+ refreshBufferList()
+ }
+}
+
+// --- Buffer actions ----------------------------------------------------------
+
+func bufferActivate(name string) {
+ relaySend(RelayCommandData{
+ Variant: &RelayCommandDataBufferActivate{BufferName: name},
+ }, nil)
+}
+
+func bufferToggleUnimportant(name string) {
+ relaySend(RelayCommandData{
+ Variant: &RelayCommandDataBufferToggleUnimportant{BufferName: name},
+ }, nil)
+}
+
+func bufferToggleLogFinish(err string, response *RelayResponseDataBufferLog) {
+ if response == nil {
+ showErrorMessage(err)
+ return
+ }
+
+ wLog.SetText(string(response.Log))
+ wLog.Show()
+ wRichScroll.Hide()
+}
+
+func bufferToggleLog() {
+ if wLog.Visible() {
+ wRichScroll.Show()
+ wLog.Hide()
+ wLog.SetText("")
+
+ recheckHighlighted()
+ return
+ }
+
+ name := bufferCurrent
+ relaySend(RelayCommandData{Variant: &RelayCommandDataBufferLog{
+ BufferName: name,
+ }}, func(err string, response *RelayResponseData) {
+ if bufferCurrent == name {
+ bufferToggleLogFinish(
+ err, response.Variant.(*RelayResponseDataBufferLog))
+ }
+ })
+}
+
// --- RichText formatting -----------------------------------------------------
func defaultBufferLineItem() bufferLineItem { return bufferLineItem{} }
@@ -450,6 +566,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 +614,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 +674,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 +700,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,
@@ -624,6 +771,7 @@ func refreshBuffer(b *buffer) {
bufferPrintAndWatchTrailingDateChanges()
wRichText.Refresh()
bufferScrollToBottom()
+ recheckHighlighted()
}
// --- Event processing --------------------------------------------------------
@@ -634,7 +782,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 +790,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 +807,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 +830,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 +848,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)
@@ -788,11 +937,11 @@ func relayProcessMessage(m *RelayEventMessage) {
b.bufferName = data.New
- refreshBufferList()
if data.BufferName == bufferCurrent {
bufferCurrent = data.New
refreshStatus()
}
+ refreshBufferList()
if data.BufferName == bufferLast {
bufferLast = data.New
}
@@ -818,15 +967,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 +996,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 +1073,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 +1116,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 +1145,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 +1154,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 +1317,173 @@ 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()
+ } else {
+ recheckHighlighted()
+ refreshStatus()
+ }
+}
+
+// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+
+// rotatedBuffers returns buffer indexes starting with the current buffer.
+func rotatedBuffers() []int {
+ r, start := make([]int, len(buffers)), 0
+ for i := range buffers {
+ if buffers[i].bufferName == bufferCurrent {
+ start = i
+ break
+ }
+ }
+ for i := range r {
+ start++
+ r[i] = start % len(r)
+ }
+ return r
+}
+
+var shortcuts = map[desktop.CustomShortcut]func(){
+ {
+ KeyName: fyne.KeyPageUp,
+ Modifier: fyne.KeyModifierControl,
+ }: func() {
+ if r := rotatedBuffers(); len(r) <= 0 {
+ } else if i := r[len(r)-1]; i == 0 {
+ bufferActivate(buffers[len(buffers)-1].bufferName)
+ } else {
+ bufferActivate(buffers[i-1].bufferName)
+ }
+ },
+ {
+ KeyName: fyne.KeyPageDown,
+ Modifier: fyne.KeyModifierControl,
+ }: func() {
+ if r := rotatedBuffers(); len(r) <= 0 {
+ } else {
+ bufferActivate(buffers[r[0]].bufferName)
+ }
+ },
+ {
+ KeyName: fyne.KeyTab,
+ Modifier: fyne.KeyModifierAlt,
+ }: func() {
+ if bufferLast != "" {
+ bufferActivate(bufferLast)
+ }
+ },
+ {
+ // XXX: This makes an assumption on the keyboard layout (we want '!').
+ KeyName: fyne.Key1,
+ Modifier: fyne.KeyModifierAlt | fyne.KeyModifierShift,
+ }: func() {
+ for _, i := range rotatedBuffers() {
+ if buffers[i].highlighted {
+ bufferActivate(buffers[i].bufferName)
+ break
+ }
+ }
+ },
+ {
+ KeyName: fyne.KeyA,
+ Modifier: fyne.KeyModifierAlt,
+ }: func() {
+ for _, i := range rotatedBuffers() {
+ if buffers[i].newMessages != 0 {
+ bufferActivate(buffers[i].bufferName)
+ break
+ }
+ }
+ },
+ {
+ KeyName: fyne.KeyH,
+ Modifier: fyne.KeyModifierAlt | fyne.KeyModifierShift,
+ }: func() {
+ if b := bufferByName(bufferCurrent); b != nil {
+ bufferToggleUnimportant(b.bufferName)
+ }
+ },
+ {
+ KeyName: fyne.KeyH,
+ Modifier: fyne.KeyModifierAlt,
+ }: func() {
+ if b := bufferByName(bufferCurrent); b != nil {
+ bufferToggleLog()
+ }
+ },
+}
+
func main() {
flag.Usage = func() {
fmt.Fprintf(flag.CommandLine.Output(),
@@ -1071,18 +1497,26 @@ 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{})
+ a.SetIcon(resourceIconNormal)
wWindow = a.NewWindow(projectName)
wWindow.Resize(fyne.NewSize(640, 480))
a.Lifecycle().SetOnEnteredForeground(func() {
// TODO(p): Does this need locking?
inForeground = true
- if b := bufferByName(bufferCurrent); b != nil {
- b.highlighted = false
- refreshIcon()
- }
+ recheckHighlighted()
})
a.Lifecycle().SetOnExitedForeground(func() {
inForeground = false
@@ -1119,60 +1553,84 @@ 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) {
+ recheckHighlighted()
+ refreshStatus()
+ }
+ wLog = newLogEntry()
+ wLog.Wrapping = fyne.TextWrapWord
+ wLog.Hide()
+
wPrompt = widget.NewLabelWithStyle(
"", fyne.TextAlignLeading, fyne.TextStyle{Bold: true})
+ wDown = widget.NewIcon(theme.MoveDownIcon())
wStatus = widget.NewLabelWithStyle(
"", fyne.TextAlignTrailing, fyne.TextStyle{})
-
- wEntry = 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()