Fork me on GitHub
#membrane
<
2022-12-24
>
adamfrey15:12:40

I could use some guidance for a UI I'm trying to build with defui components. This is a simplified version of the app I am trying to build. In general there is a grid of photo thumbnails, and when you click on a thumbnail the large version of the image opens up in a carousel that can be navigated left/right. While a photo is focused you can modify it (demoed here with a toggle) and it should update the photo data in the state atom. In a CLJS/re-frame application I'm used to keeping my state in a more "normalized database" structure, but membrane.component with its specter and for support seems to advocate for keeping the data in a vector in a situation like this. In the ::focus-photo effect I'm peering into the specter path in way that seems unsatisfying and brittle to me

(defonce *example-state
  (atom {:focused-index nil
         :photos [{:name "img1" :toggled? false}
                  {:name "img2" :toggled? false}
                  {:name "img3" :toggled? false}
                  {:name "img4" :toggled? false}]}))

(defui photo-row
  [{:keys [photo]}]
  (let [{:keys [name toggled?]} photo]
    (ui/horizontal-layout
     (ui/on-click (fn [& _]
                    [[::focus-photo $photo]])
                  (ui/label name))
     (ui/checkbox toggled?))))

(defui focused-photo
  [{:keys [focused-index photo last-index]}]
  (let [{:keys [name toggled?]} photo]
    (ui/on-key-press
     (fn [k]
       (case k
         (:right :space "n") [[:set $focused-index
                               (if (= focused-index last-index)
                                 0
                                 (inc focused-index))]]
         (:left "p")         [[:set $focused-index
                               (if (zero? focused-index)
                                 last-index
                                 (dec focused-index))]]
         (:escape "q")       [[:set $focused-index nil]]
         "t"                 [[:update $toggled? not]]
         nil))
     (ui/horizontal-layout
      (ui/label (str "Focus " name))
      (ui/checkbox toggled?)))))

(defui example-root
  [{:keys [photos focused-index]}]
  (apply
   ui/vertical-layout
   (concat
     (for [photo photos]
       (photo-row {:photo         photo
                   :focused-index focused-index}))
     [(when focused-index
        (let [photo (get photos focused-index)]
          (focused-photo {:focused-index focused-index
                          :photo         photo
                          :last-index    (dec (count photos))} )))])))

(defeffect ::focus-photo
  [$photo]
  (prn "photo path >>>" $photo)
  ;; TODO improve this
  (let [index (-> $photo second second)]
    (dispatch! :set '[(keypath :focused-index)] index)))

(defonce example-app (mem.component/make-app #'example-root *example-state))

adamfrey16:12:39

As a follow up I'm experimenting with a version where the state contains a "by id" (`:name`) map for the photos entities and then a separate vector for the list view:

(def initial-state
  {:focused-photo-index nil
   :photos-by-name      {"img1" {:name "img1", :toggled? false},
                         "img2" {:name "img2", :toggled? false},
                         "img3" {:name "img3", :toggled? false},
                         "img4" {:name "img4", :toggled? false}}
   :photos              ["img1"
                         "img2"
                         "img3"
                         "img4"]})

(defonce *example-state
  (atom initial-state))

(defui photo-row
  [{:keys [photo]}]
  (let [{:keys [name toggled?]} photo]
    (ui/horizontal-layout
     (ui/on-click (fn [& _]
                    [[::focus-photo name]])
                  (ui/label name))
     (ui/checkbox toggled?))))

(defui focused-photo
  [{:keys [photo]}]
  (let [{:keys [name toggled?]} photo]
    (ui/on-key-press
     (fn [k]
       (case k
         "t" [[:update $toggled? not]]
         nil))
     (ui/horizontal-layout
      (ui/label (str "Focus " name))
      (ui/checkbox toggled?)))))

(defn- keep-index-in-bounds [list-count]
  (fn [i]
    (cond
      (neg? i)               (dec list-count)
      (< (dec list-count) i) 0
      :else                  i)))

(defui example-root
  [{:keys [context photos photos-by-name focused-photo-index]}]
  (apply
   ui/vertical-layout
   (concat
     (for [photo-name photos]
       (let [photo (get photos-by-name photo-name)]
         (photo-row {:photo photo})))
     [(when focused-photo-index
        (let [focused-photo-name (get photos focused-photo-index)
              photo              (get photos-by-name focused-photo-name)]
          (ui/wrap-on
           :key-press
           (fn [child-handler k]
             (concat
               (child-handler k)
               (case k
                 (:right :space "n") [[:update $focused-photo-index (comp (keep-index-in-bounds (count photos)) inc)]]
                 (:left "p")         [[:update $focused-photo-index (comp (keep-index-in-bounds (count photos)) dec)]]
                 (:escape "q")       [[:update $focused-photo-index nil]]
                 nil)))
           (focused-photo {:photo photo}))))])))

(defeffect ::focus-photo
  [photo-name]
  (swap! *example-state
         (fn [state]
           (let [index (.indexOf (:photos state) photo-name)]
             (assoc state :focused-photo-index index)))))

(defonce example-app (mem.component/make-app #'example-root *example-state))
I'm interested in what people think about the two options in comparison

phronmophobic17:12:36

> but membrane.component with its specter and for support seems to advocate for keeping the data in a vector in a situation like this. Membrane intentionally tries to avoid imposing any sort of structure on your data model. If you use a value like seq, then it will get treated like a seq. If you use a value like a map, it will get treated like a map. If there are data types that feel awkward in membrane, then I think it's worth brainstorming solutions.

phronmophobic17:12:20

It seems like your second version is a good example of working with maps. It's also perfectly fine to do something like:

(for [k (keys m)]
  (my-view (get m k)))

phronmophobic17:12:05

another option is to do something like:

;; child
(defui aview [{:keys [obj]}]
  (ui/on
   :mouse-down
   (fn [_]
     [[::select]])))

;; in parent
(for [[k v] m]
  (ui/on
   ::select
   (fn []
     [[::select k]])
   (aview {:obj v})))

phronmophobic17:12:46

There are ordered-map implementations that might be helpful as well.

phronmophobic19:12:20

I like your second version over the first. The only suggestions I might have are: • check to see if any of the ordered map implementations help • I would probably have the focus use the photo identifier (the photo's name in this case) rather than index . The idea is that if you add, rearrange, or remove photos, you probably want the focused element to refer to the same photo.

adamfrey20:12:11

Thanks, @U7RJTCH6J. All of that is very helpful