// Copyright (c) 2024, Přemysl Eric Janouch // SPDX-License-Identifier: 0BSD package main import ( "bufio" "bytes" "context" _ "embed" "encoding/binary" "errors" "flag" "fmt" "image/color" "io" "log" "net" "net/url" "os" "regexp" "slices" "strings" "sync" "time" "github.com/ebitengine/oto/v3" "fyne.io/fyne/v2" "fyne.io/fyne/v2/app" "fyne.io/fyne/v2/container" "fyne.io/fyne/v2/dialog" "fyne.io/fyne/v2/driver/desktop" "fyne.io/fyne/v2/driver/mobile" "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/widget" ) var ( debug = flag.Bool("debug", false, "enable debug output") projectName = "xA" projectVersion = "?" //go:embed xA.png iconNormal []byte //go:embed xA-highlighted.png iconHighlighted []byte //go:embed beep.raw beepSample []byte resourceIconNormal = fyne.NewStaticResource( "xA.png", iconNormal) resourceIconHighlighted = fyne.NewStaticResource( "xA-highlighted.png", iconHighlighted) ) // --- Theme ------------------------------------------------------------------- type customTheme struct{} const ( colorNameRenditionError fyne.ThemeColorName = "renditionError" colorNameRenditionJoin fyne.ThemeColorName = "renditionJoin" colorNameRenditionPart fyne.ThemeColorName = "renditionPart" colorNameRenditionAction fyne.ThemeColorName = "renditionAction" colorNameBufferTimestamp fyne.ThemeColorName = "bufferTimestamp" colorNameBufferLeaked fyne.ThemeColorName = "bufferLeaked" ) func convertColor(c int) color.Color { base16 := []uint16{ 0x000, 0x800, 0x080, 0x880, 0x008, 0x808, 0x088, 0xccc, 0x888, 0xf00, 0x0f0, 0xff0, 0x00f, 0xf0f, 0x0ff, 0xfff, } if c < 16 { r := 0xf & uint8(base16[c]>>8) g := 0xf & uint8(base16[c]>>4) b := 0xf & uint8(base16[c]) return color.RGBA{r * 0x11, g * 0x11, b * 0x11, 0xff} } if c >= 216 { return color.Gray{8 + uint8(c-216)*10} } var ( i = uint8(c - 16) r = i / 36 >> 0 g = (i / 6 >> 0) % 6 b = i % 6 ) if r != 0 { r = 55 + 40*r } if g != 0 { g = 55 + 40*g } if b != 0 { b = 55 + 40*b } return color.RGBA{r, g, b, 0xff} } var ircColors = make(map[fyne.ThemeColorName]color.Color) func ircColorName(color int) fyne.ThemeColorName { return fyne.ThemeColorName(fmt.Sprintf("irc%02x", color)) } func init() { for color := 0; color < 256; color++ { ircColors[ircColorName(color)] = convertColor(color) } } func (t *customTheme) Color( name fyne.ThemeColorName, variant fyne.ThemeVariant) color.Color { /* // Fyne may use a dark background with the Light variant, // which makes the UI unusable. if runtime.GOOS == "android" { variant = theme.VariantDark } */ // Fuck this low contrast shit, text must be black. if name == theme.ColorNameForeground && variant == theme.VariantLight { return color.Black } switch name { case colorNameRenditionError: return color.RGBA{0xff, 0x00, 0x00, 0xff} case colorNameRenditionJoin: return color.RGBA{0x00, 0x88, 0x00, 0xff} case colorNameRenditionPart: return color.RGBA{0x88, 0x00, 0x00, 0xff} case colorNameRenditionAction: return color.RGBA{0x88, 0x00, 0x00, 0xff} case colorNameBufferTimestamp, colorNameBufferLeaked: return color.RGBA{0x88, 0x88, 0x88, 0xff} } if c, ok := ircColors[name]; ok { return c } return theme.DefaultTheme().Color(name, variant) } func (t *customTheme) Font(style fyne.TextStyle) fyne.Resource { return theme.DefaultTheme().Font(style) } func (t *customTheme) Icon(i fyne.ThemeIconName) fyne.Resource { return theme.DefaultTheme().Icon(i) } func (t *customTheme) Size(s fyne.ThemeSizeName) float32 { switch s { case theme.SizeNameInnerPadding: return 2 default: return theme.DefaultTheme().Size(s) } } // --- Relay state ------------------------------------------------------------- type server struct { state RelayServerState user string userModes string } type bufferLineItem struct { format fyne.TextStyle // For RichTextStyle.ColorName. color fyne.ThemeColorName // XXX: Fyne's RichText doesn't support background colours. background fyne.ThemeColorName text string link *url.URL } type bufferLine struct { /// Leaked from another buffer, but temporarily staying in another one. leaked bool isUnimportant bool isHighlight bool rendition RelayRendition when time.Time items []bufferLineItem } type buffer struct { bufferName string hideUnimportant bool kind RelayBufferKind serverName string lines []bufferLine // Channel: topic []bufferLineItem modes string // Stats: newMessages int newUnimportantMessages int highlighted bool // Input: input string inputRow, inputColumn int history []string historyAt int } type callback func(err string, response *RelayResponseData) const ( preferenceAddress = "address" ) var ( backendAddress string backendContext context.Context backendCancel context.CancelFunc backendConn net.Conn backendLock sync.Mutex // Connection state: commandSeq uint32 commandCallbacks = make(map[uint32]callback) buffers []buffer bufferCurrent string bufferLast string servers = make(map[string]*server) // Sound: otoContext *oto.Context otoReady chan struct{} // Widgets: inForeground = true wConnect *dialog.FormDialog wWindow fyne.Window wTopic *widget.RichText wBufferList *widget.List wRichText *widget.RichText wRichScroll *container.Scroll wLog *logEntry wPrompt *widget.Label wDown *widget.Icon wStatus *widget.Label wEntry *inputEntry ) // ----------------------------------------------------------------------------- func showErrorMessage(text string) { dialog.ShowError(errors.New(text), wWindow) } func beep() { if otoContext == nil { return } go func() { <-otoReady otoContext.NewPlayer(bytes.NewReader(beepSample)).Play() }() } // --- Networking -------------------------------------------------------------- func relayReadMessage(r io.Reader) (m RelayEventMessage, ok bool) { var length uint32 if err := binary.Read(r, binary.BigEndian, &length); err != nil { log.Println("Event receive failed: " + err.Error()) return } b := make([]byte, length) if _, err := io.ReadFull(r, b); err != nil { log.Println("Event receive failed: " + err.Error()) return } if after, ok2 := m.ConsumeFrom(b); !ok2 { log.Println("Event deserialization failed") return } else if len(after) != 0 { log.Println("Event deserialization failed: trailing data") return } if *debug { log.Printf(" %v\n", b) } 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) } 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 { return wRichScroll.Offset.Y >= wRichScroll.Content.Size().Height-wRichScroll.Size().Height } func bufferScrollToBottom() { // XXX: Doing it once is not reliable, something's amiss. // (In particular, nothing happens when we switch from an empty buffer // to a buffer than needs scrolling.) wRichScroll.ScrollToBottom() wRichScroll.ScrollToBottom() refreshStatus() } // --- UI state refresh -------------------------------------------------------- func refreshIcon() { highlighted := false for _, b := range buffers { if b.highlighted { highlighted = true break } } if highlighted { wWindow.SetIcon(resourceIconHighlighted) } else { wWindow.SetIcon(resourceIconNormal) } } func refreshTopic(topic []bufferLineItem) { wTopic.Segments = nil for _, item := range topic { if item.link != nil { wTopic.Segments = append(wTopic.Segments, &widget.HyperlinkSegment{Text: item.text, URL: item.link}) continue } wTopic.Segments = append(wTopic.Segments, &widget.TextSegment{ Text: item.text, Style: widget.RichTextStyle{ Alignment: fyne.TextAlignLeading, ColorName: item.color, Inline: true, SizeName: theme.SizeNameText, TextStyle: item.format, }, }) } wTopic.Refresh() } func refreshBufferList() { // This seems to be enough, even for removals. for i := range buffers { wBufferList.RefreshItem(widget.ListItemID(i)) } } func refreshPrompt() { var prompt string if b := bufferByName(bufferCurrent); b == nil { prompt = "Synchronizing..." } else if server, ok := servers[b.serverName]; ok { prompt = server.user if server.userModes != "" { prompt += "(" + server.userModes + ")" } if prompt == "" { prompt = "(" + server.state.String() + ")" } } wPrompt.SetText(prompt) } func refreshStatus() { if bufferAtBottom() { wDown.Hide() } else { wDown.Show() } status := bufferCurrent if b := bufferByName(bufferCurrent); b != nil { if b.modes != "" { status += "(+" + b.modes + ")" } if b.hideUnimportant { status += "" } } wStatus.SetText(status) } // --- RichText formatting ----------------------------------------------------- func defaultBufferLineItem() bufferLineItem { return bufferLineItem{} } func convertItemFormatting( item RelayItemData, cf *bufferLineItem, inverse *bool) { switch data := item.Variant.(type) { case *RelayItemDataReset: *cf = defaultBufferLineItem() case *RelayItemDataFlipBold: cf.format.Bold = !cf.format.Bold case *RelayItemDataFlipItalic: cf.format.Italic = !cf.format.Italic case *RelayItemDataFlipUnderline: cf.format.Underline = !cf.format.Underline case *RelayItemDataFlipCrossedOut: // https://github.com/fyne-io/fyne/issues/1084 case *RelayItemDataFlipInverse: *inverse = !*inverse case *RelayItemDataFlipMonospace: cf.format.Monospace = !cf.format.Monospace case *RelayItemDataFgColor: if data.Color < 0 { cf.color = "" } else { cf.color = ircColorName(int(data.Color)) } case *RelayItemDataBgColor: if data.Color < 0 { cf.background = "" } else { cf.background = ircColorName(int(data.Color)) } } } var linkRE = regexp.MustCompile(`https?://` + `(?:[^\[\](){}<>"'\s]|\([^\[\](){}<>"'\s]*\))+` + `(?:[^\[\](){}<>"'\s,.:]|\([^\[\](){}<>"'\s]*\))`) func convertLinks( item bufferLineItem, items []bufferLineItem) []bufferLineItem { end, matches := 0, linkRE.FindAllStringIndex(item.text, -1) for _, m := range matches { url, _ := url.Parse(item.text[m[0]:m[1]]) if url == nil { continue } if end < m[0] { subitem := item subitem.text = item.text[end:m[0]] items = append(items, subitem) } subitem := item subitem.text = item.text[m[0]:m[1]] subitem.link = url items = append(items, subitem) end = m[1] } if end < len(item.text) { subitem := item subitem.text = item.text[end:] items = append(items, subitem) } return items } func convertItems(items []RelayItemData) []bufferLineItem { result := []bufferLineItem{} cf, inverse := defaultBufferLineItem(), false for _, it := range items { text, ok := it.Variant.(*RelayItemDataText) if !ok { convertItemFormatting(it, &cf, &inverse) continue } item := cf item.text = text.Text if inverse { item.color, item.background = item.background, item.color } result = convertLinks(item, result) } return result } // --- Buffer output ----------------------------------------------------------- func convertBufferLine(m *RelayEventDataBufferLine) bufferLine { return bufferLine{ items: convertItems(m.Items), isUnimportant: m.IsUnimportant, isHighlight: m.IsHighlight, rendition: m.Rendition, when: time.UnixMilli(int64(m.When)), } } func bufferPrintDateChange(last, current time.Time) { last, current = last.Local(), current.Local() if last.Year() == current.Year() && last.Month() == current.Month() && last.Day() == current.Day() { return } wRichText.Segments = append(wRichText.Segments, &widget.TextSegment{ Style: widget.RichTextStyle{ Alignment: fyne.TextAlignLeading, ColorName: "", Inline: false, SizeName: theme.SizeNameText, TextStyle: fyne.TextStyle{Bold: true}, }, Text: current.Format(time.DateOnly), }) } func bufferPrintAndWatchTrailingDateChanges() { current := time.Now() b := bufferByName(bufferCurrent) if b != nil && len(b.lines) != 0 { last := b.lines[len(b.lines)-1].when bufferPrintDateChange(last, current) } // TODO(p): The watching part. } func bufferPrintLine(lines []bufferLine, index int) { line := &lines[index] last, current := time.Time{}, line.when if index == 0 { last = time.Now() } else { last = lines[index-1].when } bufferPrintDateChange(last, current) texts := []widget.RichTextSegment{&widget.TextSegment{ Text: line.when.Format("15:04:05 "), Style: widget.RichTextStyle{ Alignment: fyne.TextAlignLeading, ColorName: colorNameBufferTimestamp, Inline: true, SizeName: theme.SizeNameText, TextStyle: fyne.TextStyle{}, }}} // Tabstops won't quite help us here, since we need it centred. prefix := "" pcf := widget.RichTextStyle{ Alignment: fyne.TextAlignLeading, Inline: true, SizeName: theme.SizeNameText, TextStyle: fyne.TextStyle{Monospace: true}, } switch line.rendition { case RelayRenditionBare: case RelayRenditionIndent: prefix = " " case RelayRenditionStatus: prefix = " - " case RelayRenditionError: prefix = "=!= " pcf.ColorName = colorNameRenditionError case RelayRenditionJoin: prefix = "--> " pcf.ColorName = colorNameRenditionJoin case RelayRenditionPart: prefix = "<-- " pcf.ColorName = colorNameRenditionPart case RelayRenditionAction: prefix = " * " pcf.ColorName = colorNameRenditionAction } if prefix != "" { style := pcf if line.leaked { style.ColorName = colorNameBufferLeaked } texts = append(texts, &widget.TextSegment{ Text: prefix, Style: style, }) } for _, item := range line.items { if item.link != nil { texts = append(texts, &widget.HyperlinkSegment{Text: item.text, URL: item.link}) continue } style := widget.RichTextStyle{ Alignment: fyne.TextAlignLeading, ColorName: item.color, Inline: true, SizeName: theme.SizeNameText, TextStyle: item.format, } if line.leaked { style.ColorName = colorNameBufferLeaked } texts = append(texts, &widget.TextSegment{ Text: item.text, Style: style, }) } wRichText.Segments = append(wRichText.Segments, &widget.ParagraphSegment{Texts: texts}, &widget.TextSegment{Style: widget.RichTextStyleParagraph}) } func bufferPrintSeparator() { // TODO(p): Implement our own, so that it can use the primary colour. wRichText.Segments = append(wRichText.Segments, &widget.SeparatorSegment{}) } func refreshBuffer(b *buffer) { wRichText.Segments = nil markBefore := len(b.lines) - b.newMessages - b.newUnimportantMessages for i, line := range b.lines { if i == markBefore { bufferPrintSeparator() } if !line.isUnimportant || !b.hideUnimportant { bufferPrintLine(b.lines, i) } } bufferPrintAndWatchTrailingDateChanges() wRichText.Refresh() bufferScrollToBottom() } // --- Event processing -------------------------------------------------------- func relayProcessBufferLine(b *buffer, m *RelayEventDataBufferLine) { line := convertBufferLine(m) // Initial sync: skip all other processing, let highlights be. bc := bufferByName(bufferCurrent) if bc == nil { bufferPushLine(b, line) return } // Retained mode is complicated. display := (!m.IsUnimportant || !bc.hideUnimportant) && (b.bufferName == bufferCurrent || m.LeakToActive) toBottom := display && bufferAtBottom() visible := display && toBottom && inForeground && !wLog.Visible() separate := display && !visible && bc.newMessages == 0 && bc.newUnimportantMessages == 0 bufferPushLine(b, 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 bufferPushLine(bc, 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. // Though we probably can't run in the background. if line.isHighlight || (!visible && !line.isUnimportant && b.kind == RelayBufferKindPrivateMessage) { beep() if !visible { b.highlighted = true refreshIcon() } } refreshBufferList() } func relayProcessCallbacks( commandSeq uint32, err string, response *RelayResponseData) { if handler, ok := commandCallbacks[commandSeq]; !ok { if *debug { log.Printf("Unawaited response: %+v\n", *response) } } else { delete(commandCallbacks, commandSeq) if handler != nil { handler(err, response) } } // We don't particularly care about wraparound issues. for cs, handler := range commandCallbacks { if cs <= commandSeq { delete(commandCallbacks, cs) if handler != nil { handler("No response", nil) } } } } func relayProcessMessage(m *RelayEventMessage) { switch data := m.Data.Variant.(type) { case *RelayEventDataError: relayProcessCallbacks(data.CommandSeq, data.Error, nil) case *RelayEventDataResponse: relayProcessCallbacks(data.CommandSeq, "", &data.Data) case *RelayEventDataPing: relaySend(RelayCommandData{ Variant: &RelayCommandDataPingResponse{EventSeq: m.EventSeq}, }, nil) case *RelayEventDataBufferLine: b := bufferByName(data.BufferName) if b == nil { return } relayProcessBufferLine(b, data) case *RelayEventDataBufferUpdate: b := bufferByName(data.BufferName) if b == nil { buffers = append(buffers, buffer{bufferName: data.BufferName}) b = &buffers[len(buffers)-1] refreshBufferList() } hidingToggled := b.hideUnimportant != data.HideUnimportant b.hideUnimportant = data.HideUnimportant b.kind = data.Context.Variant.Kind() b.serverName = "" switch context := data.Context.Variant.(type) { case *RelayBufferContextServer: b.serverName = context.ServerName case *RelayBufferContextChannel: b.serverName = context.ServerName b.modes = context.Modes b.topic = convertItems(context.Topic) case *RelayBufferContextPrivateMessage: b.serverName = context.ServerName } if b.bufferName == bufferCurrent { refreshTopic(b.topic) refreshStatus() if hidingToggled { refreshBuffer(b) } } case *RelayEventDataBufferStats: b := bufferByName(data.BufferName) if b == nil { return } b.newMessages = int(data.NewMessages) b.newUnimportantMessages = int(data.NewUnimportantMessages) b.highlighted = data.Highlighted refreshIcon() case *RelayEventDataBufferRename: b := bufferByName(data.BufferName) if b == nil { return } b.bufferName = data.New refreshBufferList() if data.BufferName == bufferCurrent { bufferCurrent = data.New refreshStatus() } if data.BufferName == bufferLast { bufferLast = data.New } case *RelayEventDataBufferRemove: buffers = slices.DeleteFunc(buffers, func(b buffer) bool { return b.bufferName == data.BufferName }) refreshBufferList() refreshIcon() case *RelayEventDataBufferActivate: old := bufferByName(bufferCurrent) bufferLast = bufferCurrent bufferCurrent = data.BufferName b := bufferByName(data.BufferName) if b == nil { return } if old != nil { old.newMessages = 0 old.newUnimportantMessages = 0 old.highlighted = false old.input = wEntry.Text old.inputRow = wEntry.CursorRow old.inputColumn = wEntry.CursorColumn // Note that we effectively overwrite the newest line // with the current textarea contents, and jump there. old.historyAt = len(old.history) } if wLog.Visible() { bufferToggleLog() } if inForeground { b.highlighted = false } for i := range buffers { if buffers[i].bufferName == bufferCurrent { wBufferList.Select(widget.ListItemID(i)) break } } refreshIcon() refreshTopic(b.topic) refreshBufferList() refreshBuffer(b) refreshPrompt() refreshStatus() wEntry.SetText(b.input) wEntry.CursorRow = b.inputRow wEntry.CursorColumn = b.inputColumn wEntry.Refresh() wWindow.Canvas().Focus(wEntry) case *RelayEventDataBufferInput: b := bufferByName(data.BufferName) if b == nil { return } if b.historyAt == len(b.history) { b.historyAt++ } b.history = append(b.history, data.Text) case *RelayEventDataBufferClear: b := bufferByName(data.BufferName) if b == nil { return } b.lines = nil if b.bufferName == bufferCurrent { refreshBuffer(b) } case *RelayEventDataServerUpdate: s, existed := servers[data.ServerName] if !existed { s = &server{} servers[data.ServerName] = s } s.state = data.Data.Variant.State() switch state := data.Data.Variant.(type) { case *RelayServerDataRegistered: s.user = state.User s.userModes = state.UserModes default: s.user = "" s.userModes = "" } refreshPrompt() case *RelayEventDataServerRename: servers[data.New] = servers[data.ServerName] delete(servers, data.ServerName) case *RelayEventDataServerRemove: delete(servers, data.ServerName) } } // --- Networking -------------------------------------------------------------- func relayMakeReceiver( ctx context.Context, conn net.Conn) <-chan RelayEventMessage { // The usual event message rarely gets above 1 kilobyte, // thus this is set to buffer up at most 1 megabyte or so. p := make(chan RelayEventMessage, 1000) r := bufio.NewReaderSize(conn, 65536) go func() { defer close(p) for { m, ok := relayReadMessage(r) if !ok { return } select { case p <- m: case <-ctx.Done(): return } } }() return p } func relayResetState() { commandSeq = 0 commandCallbacks = make(map[uint32]callback) buffers = nil bufferCurrent = "" bufferLast = "" servers = make(map[string]*server) refreshIcon() refreshTopic(nil) refreshBufferList() wRichText.ParseMarkdown("") refreshPrompt() refreshStatus() } func relayRun() { fyne.CurrentApp().Preferences().SetString(preferenceAddress, backendAddress) backendLock.Lock() relayResetState() backendContext, backendCancel = context.WithCancel(context.Background()) defer backendCancel() var err error backendConn, err = net.Dial("tcp", backendAddress) backendLock.Unlock() if err != nil { wConnect.Show() showErrorMessage("Connection failed: " + err.Error()) return } defer backendConn.Close() // TODO(p): Figure out locking. // - Messages are currently sent (semi-)synchronously, directly. // - Is the net.Conn actually async-safe? relaySend(RelayCommandData{ Variant: &RelayCommandDataHello{Version: RelayVersion}, }, nil) relayMessages := relayMakeReceiver(backendContext, backendConn) Loop: for { select { case m, ok := <-relayMessages: if !ok { break Loop } relayProcessMessage(&m) } } wConnect.Show() showErrorMessage("Disconnected") } // --- Input line -------------------------------------------------------------- func inputSetContents(input string) { wEntry.SetText(input) } func inputSubmit(text string) bool { b := bufferByName(bufferCurrent) if b == nil { return false } b.history = append(b.history, text) b.historyAt = len(b.history) inputSetContents("") relaySend(RelayCommandData{Variant: &RelayCommandDataBufferInput{ BufferName: b.bufferName, Text: text, }}, nil) return true } type inputStamp struct { cursorRow, cursorColumn int input string } func inputGetStamp() inputStamp { return inputStamp{ cursorRow: wEntry.CursorRow, cursorColumn: wEntry.CursorColumn, input: wEntry.Text, } } func inputCompleteFinish(state inputStamp, err string, response *RelayResponseDataBufferComplete) { if response == nil { showErrorMessage(err) return } if len(response.Completions) > 0 { insert := response.Completions[0] if len(response.Completions) == 1 { insert += " " } inputSetContents(state.input[:response.Start] + insert) } if len(response.Completions) != 1 { beep() } // TODO(p): Show all completion options. } func inputComplete() bool { if wEntry.SelectedText() != "" { return false } // XXX: Fyne's Entry widget makes it impossible to handle this properly. state := inputGetStamp() relaySend(RelayCommandData{Variant: &RelayCommandDataBufferComplete{ BufferName: bufferCurrent, Text: state.input, Position: uint32(len(state.input)), }}, func(err string, response *RelayResponseData) { if stamp := inputGetStamp(); state == stamp { inputCompleteFinish(state, err, response.Variant.(*RelayResponseDataBufferComplete)) } }) return true } func inputUp() bool { b := bufferByName(bufferCurrent) if b == nil || b.historyAt < 1 { return false } if b.historyAt == len(b.history) { b.input = wEntry.Text } b.historyAt-- inputSetContents(b.history[b.historyAt]) return true } func inputDown() bool { b := bufferByName(bufferCurrent) if b == nil || b.historyAt >= len(b.history) { return false } b.historyAt++ if b.historyAt == len(b.history) { inputSetContents(b.input) } else { inputSetContents(b.history[b.historyAt]) } return true } // --- General UI -------------------------------------------------------------- type inputEntry struct { widget.Entry // selectKeyDown is a hack to exactly invert widget.Entry's behaviour, // which groups both Shift keys together. selectKeyDown bool } func newInputEntry() *inputEntry { e := &inputEntry{} e.MultiLine = true e.Wrapping = fyne.TextWrap(fyne.TextTruncateClip) e.ExtendBaseWidget(e) return e } func (e *inputEntry) FocusLost() { e.selectKeyDown = false e.Entry.FocusLost() } func (e *inputEntry) KeyDown(key *fyne.KeyEvent) { // TODO(p): And perhaps on other actions, too. relaySend(RelayCommandData{Variant: &RelayCommandDataActive{}}, nil) // Modified events are eaten somewhere, not reaching TypedKey or Shortcuts. if dd, ok := fyne.CurrentApp().Driver().(desktop.Driver); ok { modifiedKey := desktop.CustomShortcut{ KeyName: key.Name, Modifier: dd.CurrentKeyModifiers()} if handler := shortcuts[modifiedKey]; handler != nil { handler() return } switch { case modifiedKey.Modifier == fyne.KeyModifierControl && modifiedKey.KeyName == fyne.KeyP: inputUp() return case modifiedKey.Modifier == fyne.KeyModifierControl && modifiedKey.KeyName == fyne.KeyN: inputDown() return } } if key.Name == desktop.KeyShiftLeft || key.Name == desktop.KeyShiftRight { e.selectKeyDown = true } e.Entry.KeyDown(key) } func (e *inputEntry) KeyUp(key *fyne.KeyEvent) { if key.Name == desktop.KeyShiftLeft || key.Name == desktop.KeyShiftRight { e.selectKeyDown = false } e.Entry.KeyUp(key) } func (e *inputEntry) TypedKey(key *fyne.KeyEvent) { if e.Disabled() { return } // Invert the Shift key behaviour here. // Notice that this will never work on mobile. shift := &fyne.KeyEvent{Name: desktop.KeyShiftLeft} switch key.Name { case fyne.KeyReturn, fyne.KeyEnter: if e.selectKeyDown { e.Entry.KeyUp(shift) e.Entry.TypedKey(key) e.Entry.KeyDown(shift) } else if e.OnSubmitted != nil { e.OnSubmitted(e.Text) } case fyne.KeyTab: if e.selectKeyDown { // This could also go through completion lists. wWindow.Canvas().FocusPrevious() } else { inputComplete() } default: e.Entry.TypedKey(key) } } func (e *inputEntry) SetText(text string) { e.Entry.SetText(text) if text != "" { e.Entry.TypedKey(&fyne.KeyEvent{Name: fyne.KeyPageDown}) } } // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - type logEntry struct { // 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(), "Usage: %s [OPTION...] [CONNECT]\n\n", os.Args[0]) flag.PrintDefaults() } flag.Parse() if flag.NArg() > 1 { flag.Usage() os.Exit(1) } var err error otoContext, otoReady, err = oto.NewContext(&oto.NewContextOptions{ SampleRate: 44100, ChannelCount: 1, Format: oto.FormatSignedInt16LE, }) if err != nil { log.Println(err) } a := app.New() a.Settings().SetTheme(&customTheme{}) wWindow = a.NewWindow(projectName) wWindow.Resize(fyne.NewSize(640, 480)) a.Lifecycle().SetOnEnteredForeground(func() { // TODO(p): Does this need locking? inForeground = true if b := bufferByName(bufferCurrent); b != nil { b.highlighted = false refreshIcon() } }) a.Lifecycle().SetOnExitedForeground(func() { inForeground = false }) // TODO(p): Consider using data bindings. wBufferList = widget.NewList(func() int { return len(buffers) }, func() fyne.CanvasObject { return widget.NewLabel(strings.Repeat(" ", 16)) }, func(id widget.ListItemID, item fyne.CanvasObject) { label, b := item.(*widget.Label), &buffers[int(id)] label.TextStyle.Italic = b.bufferName == bufferCurrent label.TextStyle.Bold = false text := b.bufferName if b.bufferName != bufferCurrent && b.newMessages != 0 { label.TextStyle.Bold = true text += fmt.Sprintf(" (%d)", b.newMessages) } label.Importance = widget.MediumImportance if b.highlighted { label.Importance = widget.HighImportance } label.SetText(text) }) wBufferList.HideSeparators = true wBufferList.OnSelected = func(id widget.ListItemID) { // TODO(p): See if we can deselect it now without consequences. request := buffers[int(id)].bufferName if request != bufferCurrent { bufferActivate(request) } } wTopic = widget.NewRichText() wTopic.Truncation = fyne.TextTruncateEllipsis wRichText = widget.NewRichText() wRichText.Wrapping = fyne.TextWrapWord wRichScroll = container.NewVScroll(wRichText) wRichScroll.OnScrolled = func(position fyne.Position) { refreshStatus() } wLog = newLogEntry() wLog.Wrapping = fyne.TextWrapWord wLog.Hide() wPrompt = widget.NewLabelWithStyle( "", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}) wDown = widget.NewIcon(theme.MoveDownIcon()) wStatus = widget.NewLabelWithStyle( "", fyne.TextAlignTrailing, fyne.TextStyle{}) wEntry = newInputEntry() wEntry.OnSubmitted = func(text string) { inputSubmit(text) } top := container.NewVBox( wTopic, widget.NewSeparator(), ) split := container.NewHSplit(wBufferList, container.New(&customLayout{}, wRichScroll, wLog)) split.SetOffset(0.25) bottom := container.NewVBox( widget.NewSeparator(), container.NewBorder(nil, nil, wPrompt, container.NewHBox(wDown, wStatus)), wEntry, ) wWindow.SetContent(container.NewBorder(top, bottom, nil, nil, split)) canvas := wWindow.Canvas() for s, handler := range shortcuts { canvas.AddShortcut(&s, func(fyne.Shortcut) { handler() }) } // --- connect := false backendAddress = a.Preferences().String(preferenceAddress) if flag.NArg() >= 1 { backendAddress = flag.Arg(0) connect = true } connectAddress := widget.NewEntry() connectAddress.SetPlaceHolder("host:port") connectAddress.SetText(backendAddress) connectAddress.TypedKey(&fyne.KeyEvent{Name: fyne.KeyPageDown}) connectAddress.Validator = func(text string) error { _, _, err := net.SplitHostPort(text) return err } // TODO(p): Mobile should not have the option to cancel at all. // The GoBack just makes us go to the background, staying useless. wConnect = dialog.NewForm("Connect to relay", "Connect", "Exit", []*widget.FormItem{ {Text: "Address:", Widget: connectAddress}, }, func(ok bool) { if ok { backendAddress = connectAddress.Text go relayRun() } else if md, ok := a.Driver().(mobile.Driver); ok { md.GoBack() wConnect.Show() } else { a.Quit() } }, wWindow) if connect { go relayRun() } else { wConnect.Show() } wWindow.ShowAndRun() }