Closed benknoble closed 2 years ago
Thanks for the report! This should be fixed now.
You're welcome :) I've been using racket/gui/easy a lot lately for a personal project, and I'm really enjoying how easy it is to build components without much boilerplate. Thank you for putting this together.
I'm struggling to correctly design the state-management, though. I'm starting to discover ideas that help, but nothing too concrete yet. I should mention I haven't really worked with racket/gui at all.
I wonder if you have any thoughts or writing on effective ways to structure states and updates for gui/easy components? Particularly when state in one component needs to affect state in another without using derived observables?
Happy to take this to another place to discuss, be it a separate issue, the discourse, or elsewhere.
Re. state management: one thing that has worked for me is a model where components rarely take in observables and, if they do, they never manipulate them directly. Instead, components always provide callbacks to the parent to decide how/when to manipulate the state. I think this is similar to what's known as "data down, actions up" in the frontend world.
Here's an example from one of my apps that shows this and how I deal with dialogs. I have a component for configuring connections to a Kafka server:
connection-dialog.rkt
:
(define (connection-dialog [conf (make-connection-conf)]
#:ok-label [ok-label "Save"]
#:ok [ok-action void]
#:cancel [cancel-action void]
#:title [title "New Connection"])
(define/obs @conf conf)
(define ((make-updater setter) _event text)
(@conf . <~ . (λ (c) (setter c text))))
(define closed-via-action? #f)
(define close-dialog void)
(define mixin
(compose1
(make-on-close-mixin
(λ ()
(unless closed-via-action?
(cancel-action void))))
(make-closing-proc-mixin
(λ (close-proc)
(set! close-dialog (λ ()
(set! closed-via-action? #t)
(close-proc)))))))
(dialog
#:title title
#:size '(500 #f)
#:mixin mixin
(vpanel
#:margin '(5 5)
(vpanel
#:stretch '(#t #f)
(labeled
"Connection name:"
(input
(@conf . ~> . connection-conf-name)
(make-updater set-connection-conf-name)))
(hpanel
(labeled
"Bootstrap server:"
(input
(@conf . ~> . connection-conf-bootstrap-server)
(make-updater set-connection-conf-bootstrap-server)))
(hpanel
#:stretch '(#f #t)
#:alignment '(left center)
(labeled
"Secure?"
#:width 70
(checkbox
#:checked? (@conf . ~> . connection-conf-secure?)
(λ (secure?)
(@conf . <~ . (λ (c) (set-connection-conf-secure? c secure?))))))))
(hpanel
(labeled
"SASL username:"
(input
(@conf . ~> . connection-conf-sasl-username)
(make-updater set-connection-conf-sasl-username)))
(labeled
"Password:"
#:width 80
(input
#:style '(single password)
(@conf . ~> . connection-conf-sasl-password)
(make-updater set-connection-conf-sasl-password)))))
(hpanel
#:stretch '(#t #f)
#:alignment '(right top)
(ok-cancel-buttons
(button #:style '(border) ok-label (λ () (ok-action (obs-peek @conf) close-dialog)))
(button "Cancel" (λ () (cancel-action close-dialog))))))))
This is completely self-contained and I can run it on its own just to test it:
(module+ main
(render
(connection-dialog
#:ok (lambda (conf close-dialog)
(close-dialog)
(println conf))
#:cancel (lambda (close-dialog)
(close-dialog)
(println "canceled")))))
One level up, I have a component that lists sets of connections.
connection-list.rkt
:
(define (connection-list @conns [action void])
(canvas-list
(@conns . ~> . (lambda (conns)
(append conns `(,nc))))
(lambda (event item mouse-event)
(case event
[(commit)
(if (eq? item nc)
(action 'new #f #f)
(action 'connect item #f))]
[(context)
(unless (eq? item nc)
(action 'context item mouse-event))]))
#:item-height 32
#:paint (lambda (item state dc w h)
(define label
(if (eq? item nc)
"New Connection..."
(connection-conf-name item)))
(p:draw-pict (item-pict label state w h) dc 0 0))))
This one does take an observable as input, but it doesn't change it directly. It just calls action
and lets the parent deal w/ state changes. Because it's self-contained, I can test it on its own, or I can combine it with the connection-dialog
component:
(module+ main
(require "connection-dialog.rkt")
(define/obs @connections
(list
(make-connection-conf #:name "Example 1")
(make-connection-conf #:name "Example 2")))
(define root
(render
(window
#:title "Connections"
#:size '(700 400)
(connection-list
@connections
(lambda (event item mouse-event)
(case event
[(new)
(render
(connection-dialog
#:ok (λ (conf close-dialog)
(@connections . <~ . (λ (conns) (cons conf conns)))
(close-dialog))
#:cancel (λ (close-dialog)
(close-dialog)))
root)])))))))
Here, my main
module is the topmost level for this particular view hierarchy, so it's the level at which all the actions combine to actually manipulate the state (trivially, in this case, but you can imagine more complex examples).
The result looks like this:
https://user-images.githubusercontent.com/43347/162497264-82957162-1984-497d-89cb-e82fd6e2914a.mov
Hopefully that makes sense and helps. These snippets are from an app that I haven't yet open sourced so I don't have a better way of sharing them right now.
It's going to take me some time to digest this, but that's certainly helpful. Thanks for putting the effort in. I think my lack of experience with racket/gui makes it harder to appreciate certain arguments (e.g., mixin
) for racket/gui/easy components. The idea of callbacks makes sense, thanks for that.
Is it fair to say that make-on-close-mixin
creates a mixin that overrides on-close
from top-level-window<%>
? And similarly for make-closing-proc-mixin
, except that I cannot figure out what is overridden there…
make-on-close-mixin
creates a mixin that augments on-close
so that it calls its proc argument, make-closing-proc-mixin
creates a mixin that calls its proc argument with a function that will close the window when applied. Here they are:
(provide
make-closing-proc-mixin)
;; Dialogs need to be closed, but rendering a dialog yields so there's
;; no way to retrieve a dialog's renderer from within itself. This
;; may be another argument for gui-easy providing a managed
;; `current-renderer'. In the mean time, we can abuse mixins for this
;; purpose.
(define ((make-closing-proc-mixin out) %)
(class %
(super-new)
(out (lambda ()
(when (send this can-close?)
(send this on-close)
(send this show #f))))))
(provide
make-on-close-mixin)
(define ((make-on-close-mixin proc) %)
(class %
(super-new)
(define/augment (on-close)
(proc))))
So
(list-view empty (const (text "hi")))
[a toy example] should work according to the docs but doesn't.Which is correct? For now I will make sure the first argument is observable…