diff options
Diffstat (limited to 'xA/xA.go')
-rw-r--r-- | xA/xA.go | 750 |
1 files changed, 604 insertions, 146 deletions
@@ -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() |