Search code examples
gomodel-view-controllercircular-dependencyfyne

How to avoid circular dependencies in a GUI application with fyne?


I want to add a GUI to a command line application that I have written in Go but I'm running into problems with fyne and circular dependencies.

Consider this simple example to illustrate the problem I am facing: Assume that a button triggers a time-consuming method on my model class (say fetching data or so) and I want the view to update when the task has finished.

I started by implementing a very naive and not at-all-decoupled solution, which obviously runs into a circular dependency error raised by the go compiler. Consider the following code:

main.go

package main

import (
    "my-gui/gui"
)

func main() {
    gui.Init()
}

gui/gui.go

package gui

import (
    "my-gui/model"
    //[...] fyne imports
)

var counterLabel *widget.Label

func Init() {
    myApp := app.New()
    myWindow := myApp.NewWindow("Test")

    counterLabel = widget.NewLabel("0")

    counterButton := widget.NewButton("Increment", func() {
        go model.DoTimeConsumingStuff()
    })

    content := container.NewVBox(counterLabel, counterButton)

    myWindow.SetContent(content)
    myWindow.ShowAndRun()
}

func UpdateCounterLabel(value int) {
    if counterLabel != nil {
        counterLabel.SetText(strconv.Itoa(value))
    }
}

model/model.go

package model

import (
    "my-gui/gui" // <-- this dependency is where it obviously hits the fan
    //[...]
)

var counter = 0

func DoTimeConsumingStuff() {
    time.Sleep(1 * time.Second)
    
    counter++

    fmt.Println("Counter: " + strconv.Itoa(counter))
    gui.UpdateCounterLabel(counter)
}

So I am wondering how I could properly decouple this simple app to get it working. What I thought about:

  • use fyne data binding: That should work for simple stuff such as the label text in the example above. But what if I have to update more in a very custom way according to a model's state. Say I'd have to update a button's enabled state based on a model's condition. How can this be bound to data? Is that possible at all?

  • use interfaces as in the standard MVC design pattern: I tried this as well but couldn't really get my head around it. I created a separate module that would provide an interface which could then be imported by the model class. I would then register a view that (implicitly) implements that interface with the model. But I couldn't get it to work. I assume that my understanding of go interfaces isn't really sufficient at this point.

  • short polling the model: that's just meh and certainly not what the developers of Go and/or fyne intended :-)

Can anyone please point me to an idiomatic solution for this problem? I'm probably missing something very, very basic here...


Solution

  • Return Value

    You could return the value.

    func DoTimeConsumingStuff() int {
        time.Sleep(1 * time.Second)
        counter++
        return counter
    }
    

    Then on button click you spawn an anonymous goroutine, in order to not block the UI.

    counterButton := widget.NewButton("Increment", func() {
        go func() {
            counter := model.DoTimeConsumingStuff(counterChan)
            UpdateCounterLabel(counter)
        }()      
    })
    

    Callback

    You could pass the UpdateCounterLabel function to your model function aka callback.

    func DoTimeConsumingStuff(callback func(int)) {
        time.Sleep(1 * time.Second)
        counter++
        callback(counter)
    }
    
    counterButton := widget.NewButton("Increment", func() {
        go model.DoTimeConsumingStuff(UpdateCounterLabel)
    })
    

    Channel

    Maybe you could also pass a channel to your model function. But with the above approach, this doesn't seem required. Potentially, if you have more than one counter value coming.

    func DoTimeConsumingStuff(counterChan chan int) {
        for i := 0; i < 10; i++ {
            time.Sleep(1 * time.Second)
            counter++
            counterChan <- counter
        }
        close(counterChan)
    }
    

    In the GUI you can then receive from the channel, again in a goroutine in order to not block the UI.

    counterButton := widget.NewButton("Increment", func() {
        go func() {
            counterChan := make(chan int)
            go model.DoTimeConsumingStuff(counterChan)
            for counter := range counterChan {
                UpdateCounterLabel(counter)
            }
        }()      
    })
    

    Of course, you could also use, again, a callback that you call on each iteration.