Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Example for duplex communication b/w Model & Background Goroutine #1249

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions examples/duplex/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Duplex (Model ⇋ Background Goroutine)
<img width="380" src="./duplex.gif" alt="Duplex illustration"/>

## How It Works

The system operates using two channels:

1. **Background Goroutine → Message Channel → Model**
2. **Background Goroutine ← Users Channel ← Model**

### Key Components
- **Model**:
- Listens on the `MessageChannel`.
- Sends updates to the `UsersChannel` when the selected user changes.

- **messageHandlerService (Background Goroutine)**:
- Listens on the `UsersChannel`.
- Adjusts message filtering based on the user selected in the TUI model.

This duplex communication ensures that as the selected user changes in the Model, the `MessageHandlerService` dynamically adjusts its behavior.
Binary file added examples/duplex/duplex.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
66 changes: 66 additions & 0 deletions examples/duplex/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package main

import (
"context"
"fmt"
tea "github.com/charmbracelet/bubbletea"
"log"
"math"
"math/rand/v2"
"time"
)

func main() {

ctx, cancel := context.WithCancel(context.Background())

msgChan := make(chan message)
userChan := make(chan username)

go messageHandlerService(msgChan, userChan, ctx)
m := initialModel(msgChan, userChan, cancel)

if _, err := tea.NewProgram(m, tea.WithAltScreen()).Run(); err != nil {
log.Fatal(err)
}

}

type username string

type message string

var dummyUsers = [4]username{"meowgorithm", "muesli", "aymanbagabas", "bashbunni"}

// messageHandlerService is a dummy service, in a real world scenario, it could be
// - A background service that may read from a socket connection and write messages to messageChan
// - A background service tht may store messages to SQLite and then pass them to messageChan
func messageHandlerService(msgChan chan<- message, modelChan <-chan username, ctx context.Context) {
var userSelectedInModel username // the user for which we'll send messages to TUI
var i uint64
for i = 1; i <= math.MaxUint64; i++ {
select {
case <-ctx.Done():
close(msgChan)
return

// here the model tells which conversation(username) is selected in the TUI
case userSelectedInModel = <-modelChan:

case <-time.After(100 * time.Millisecond): // some delay, so our machine doesn't fly away
randIdx := rand.Uint32N(uint32(len(dummyUsers)))
randUsr := dummyUsers[randIdx]
dummyMsg := message(fmt.Sprintf("Message #%d, from %s", i, randUsr))
// save all to DB
saveToSQLite(dummyMsg, randUsr)
// send the filtered one to Model
if randUsr == userSelectedInModel {
msgChan <- dummyMsg
}
}
}
}

func saveToSQLite(msg message, u username) {
// save here
}
157 changes: 157 additions & 0 deletions examples/duplex/model.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
package main

import (
"context"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"strings"
)

var (
terminalHeight, terminalWidth int

baseStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#3FFF50"))

evenMsgStyle = baseStyle.Align(lipgloss.Center).Italic(true)
oddMsgStyle = evenMsgStyle.Faint(true)

convoUsernameStyle = baseStyle.Faint(true)
partialSelConvoUsernameStyle = convoUsernameStyle.Faint(false)
selConvoUsernameStyle = partialSelConvoUsernameStyle.Underline(true).Italic(true)
)

type Model struct {
msgVP viewport.Model
// communication from messageHandlerService to Model
msgChan <-chan message
// communication from Model to messageHandlerService
usernameChan chan<- username
murderMsgService context.CancelFunc
partialSelTabIdx int
selTabIdx int
msgs []message
}

func initialModel(msgC <-chan message, userC chan<- username, cFunc context.CancelFunc) Model {
vp := viewport.New(0, 0)
vp.Style = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder(), false, false, true, false).
BorderForeground(lipgloss.Color("#808080")).
Align(lipgloss.Center)

return Model{
msgChan: msgC,
usernameChan: userC,
murderMsgService: cFunc,
partialSelTabIdx: -1,
selTabIdx: -1,
msgVP: vp,
}

}

func (m Model) Init() tea.Cmd {
return m.listenForMsgs()
}

func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {

switch msg := msg.(type) {

case tea.WindowSizeMsg:
terminalWidth = msg.Width
terminalHeight = msg.Height
m.updateMsgVPDimensions()

case tea.KeyMsg:
switch msg.String() {
case "ctrl+c":
m.murderMsgService()
close(m.usernameChan)
return m, tea.Quit

case "tab":
m.partialSelTabIdx = (m.partialSelTabIdx + 1) % len(dummyUsers) // move tab index between usernames

case "enter":
if m.partialSelTabIdx >= 0 {
m.selTabIdx = m.partialSelTabIdx
m.usernameChan <- dummyUsers[m.selTabIdx]
}

case "esc":
m.usernameChan <- "" // no username selected
m.selTabIdx = -1
m.partialSelTabIdx = -1
}

case message:
m.msgs = append(m.msgs, msg)
m.setMsgVPContent() // set the updated msg slice render
m.msgVP.LineDown(2)
return m, tea.Batch(m.listenForMsgs(), m.handleMsgVPUpdate(msg)) // continue listening
}

return m, m.handleMsgVPUpdate(msg)

}

func (m Model) View() string {
usrBtns := m.renderConvoUserBtns()
help := lipgloss.NewStyle().Faint(true).MarginTop(1).Render("[ TAB | ENTER | ESC | CTRL + C ]")
s := lipgloss.JoinVertical(lipgloss.Center, m.msgVP.View(), usrBtns, help)
return lipgloss.Place(terminalWidth, terminalHeight, lipgloss.Center, lipgloss.Center, s)
}

// Helpers & Stuff -----------------------------------------------------------------------------------------------------

func (m *Model) updateMsgVPDimensions() {
s := strings.Join([]string{string(dummyUsers[0]), string(dummyUsers[1]), string(dummyUsers[2]), string(dummyUsers[3])}, " ")
m.msgVP.Width = lipgloss.Width(s) + 4
m.msgVP.Height = terminalHeight - 10
}

func (m *Model) handleMsgVPUpdate(msg tea.Msg) tea.Cmd {
var cmd tea.Cmd
m.msgVP, cmd = m.msgVP.Update(msg)
return cmd
}

func (m *Model) setMsgVPContent() {
var sb strings.Builder
w := m.msgVP.Width - m.msgVP.Style.GetHorizontalFrameSize()
for i, msg := range m.msgs {
// using styles to create visual hierarchy
if i%2 == 0 {
sb.WriteString(evenMsgStyle.Width(w).Render(string(msg)))
} else {
sb.WriteString(oddMsgStyle.Width(w).Render(string(msg)))
}
sb.WriteString("\n")
}
m.msgVP.SetContent(sb.String())
}

func (m Model) renderConvoUserBtns() string {
var sb strings.Builder
for i, usrname := range dummyUsers {
style := convoUsernameStyle
if i == m.selTabIdx {
style = selConvoUsernameStyle
} else if i == m.partialSelTabIdx {
style = partialSelConvoUsernameStyle
}
sb.WriteString(style.Render(string(usrname)))
if i < len(dummyUsers)-1 { // username divider
sb.WriteString(lipgloss.NewStyle().Faint(true).Render(" ⇋ "))
}
}
return sb.String()
}

func (m Model) listenForMsgs() tea.Cmd {
return func() tea.Msg {
return <-m.msgChan
}
}
10 changes: 5 additions & 5 deletions tutorials/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ require github.com/charmbracelet/bubbletea v0.25.0

require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/lipgloss v0.13.1 // indirect
github.com/charmbracelet/x/ansi v0.4.0 // indirect
github.com/charmbracelet/x/term v0.2.0 // indirect
github.com/charmbracelet/lipgloss v1.0.0 // indirect
github.com/charmbracelet/x/ansi v0.4.5 // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
Expand All @@ -18,8 +18,8 @@ require (
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.15.2 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
golang.org/x/sync v0.8.0 // indirect
golang.org/x/sys v0.26.0 // indirect
golang.org/x/sync v0.9.0 // indirect
golang.org/x/sys v0.27.0 // indirect
golang.org/x/text v0.13.0 // indirect
)

Expand Down
5 changes: 5 additions & 0 deletions tutorials/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@ github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiE
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/charmbracelet/lipgloss v0.13.1 h1:Oik/oqDTMVA01GetT4JdEC033dNzWoQHdWnHnQmXE2A=
github.com/charmbracelet/lipgloss v0.13.1/go.mod h1:zaYVJ2xKSKEnTEEbX6uAHabh2d975RJ+0yfkFpRBz5U=
github.com/charmbracelet/lipgloss v1.0.0/go.mod h1:U5fy9Z+C38obMs+T+tJqst9VGzlOYGj4ri9reL3qUlo=
github.com/charmbracelet/x/ansi v0.4.0 h1:NqwHA4B23VwsDn4H3VcNX1W1tOmgnvY1NDx5tOXdnOU=
github.com/charmbracelet/x/ansi v0.4.0/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw=
github.com/charmbracelet/x/ansi v0.4.5/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw=
github.com/charmbracelet/x/term v0.2.0 h1:cNB9Ot9q8I711MyZ7myUR5HFWL/lc3OpU8jZ4hwm0x0=
github.com/charmbracelet/x/term v0.2.0/go.mod h1:GVxgxAbjUrmpvIINHIQnJJKpMlHiZ4cktEQCN6GWyF0=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
Expand All @@ -27,9 +30,11 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=