diff --git a/examples/duplex/README.md b/examples/duplex/README.md new file mode 100644 index 0000000000..53e72f4b23 --- /dev/null +++ b/examples/duplex/README.md @@ -0,0 +1,20 @@ +# Duplex (Model ⇋ Background Goroutine) +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. diff --git a/examples/duplex/duplex.gif b/examples/duplex/duplex.gif new file mode 100644 index 0000000000..3872fc4363 Binary files /dev/null and b/examples/duplex/duplex.gif differ diff --git a/examples/duplex/main.go b/examples/duplex/main.go new file mode 100644 index 0000000000..dbf50d2e48 --- /dev/null +++ b/examples/duplex/main.go @@ -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 +} diff --git a/examples/duplex/model.go b/examples/duplex/model.go new file mode 100644 index 0000000000..6f7231a379 --- /dev/null +++ b/examples/duplex/model.go @@ -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 + } +} diff --git a/tutorials/go.mod b/tutorials/go.mod index 2d301fdb45..96a300f8c6 100644 --- a/tutorials/go.mod +++ b/tutorials/go.mod @@ -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 @@ -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 ) diff --git a/tutorials/go.sum b/tutorials/go.sum index 8906a00eca..d99991afe1 100644 --- a/tutorials/go.sum +++ b/tutorials/go.sum @@ -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= @@ -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=