Add this dependency to your project:
[com.phronemophobic/membrane "0.9.15-beta"]
All examples below will use the following namespace requires.
(:require [membrane.skia :as skia]
[membrane.ui :as ui
:refer [vertical-layout
translate
horizontal-layout
button
label
with-color
bounds
spacer
on]]
[membrane.component :as component
:refer [defui defeffect]]
[membrane.basic-components :as basic])
Below is the "Hello World!" program for membrane:
(skia/run #(ui/label "Hello World!"))
This will pop up a window that says "Hello World!".
membrane.skia/run
takes a 0 argument function that returns a value that implements the IDraw protocol.
Implementations of the IDraw protocol can be tricky and platform dependent. Thankfully, the
implementations for many graphical primitives are provided by the membrane graphics backends.
Currently, there are two supported graphics backends, skia for desktop and webgl for web.
Coordinates are represented as a vector of two numbers [x, y]
.
The membrane.ui
namespace provides graphical primitives you can use to specify your UI.
See below for quick reference of some of the graphical primitives available in membrane.ui
.
For more examples, check out the kitchen sink or if you have the membrane library as a dependency, run it with lein run -m membrane.example.kitchen-sink
.
;; label using default font
(ui/label "Hello\nWorld!")
;; label with specified font
;; font will check the default System font folder
;; on Mac osx, check /System/Library/Fonts/ for available fonts
(ui/label "Hello\nWorld!" (ui/font "Menlo.ttc" 22))
;; Use the default font, but change the size
(ui/label "Hello\nWorld!" (ui/font nil 22))
;; filled polygon
(ui/path [24.20 177.98]
[199.82 37.93]
[102.36 240.31]
[102.36 15.68]
[199.82 218.06]
[24.20 78.01]
[243.2 127.99]
[24.20 177.98])
;; line
(ui/with-style :membrane.ui/style-stroke
(ui/with-stroke-width 3
(ui/with-color [1 0 0]
(apply
ui/path
(for [i (range 10)]
[(* 30 i)
(if (even? i) 0 30)])))))
;; draw a filled rectangle
(ui/with-style :membrane.ui/style-stroke
(ui/with-stroke-width 3
(ui/with-color [0.5 0 0.5]
(ui/rectangle 100 200))))
;; rounded rectangle
(ui/rounded-rectangle 200 100 10)
;; colors are vectors of [red green blue] or [red green blue alpha]
;; with values from 0 - 1 inclusive
(ui/with-color [1 0 0]
(ui/label "Hello"))
To draw multiple elements, simply use a vector. The elements will be drawn in order.
[(ui/with-color [1 0 0 0.75]
(ui/label "red"))
(ui/with-color [0 1 0 0.75]
(ui/label "green"))
(ui/with-color [0 0 1 0.75]
(ui/label "blue"))]
translate [x y drawable]
[(ui/with-style :membrane.ui/style-stroke
[(ui/path [0 0] [0 100])
(ui/path [0 0] [60 0])])
(ui/rectangle 30 50)
(ui/translate 30 50
(ui/rectangle 30 50))]
scale [sx sy & drawables]
(ui/scale 3 10
(ui/label "sx: 3, sy: 10"))
vertical-layout [& elems]
(ui/vertical-layout
(ui/button "hello")
(ui/button "world"))
horizontal-layout [& elems]
(ui/horizontal-layout
(ui/button "hello")
(ui/button "world"))
(apply ui/horizontal-layout
(interpose
(spacer 10 0)
(for [i (range 1 5)]
(ui/with-color [0 0 0 (/ i 5.0)]
(ui/rectangle 100 50)))))
;; most layouts can be created just by using bounds
(defn center [elem [width height]]
(let [[ewidth eheight] (bounds elem)]
(translate (int (- (/ width 2)
(/ ewidth 2)))
(int (- (/ height 2)
(/ eheight 2)))
elem)))
(ui/with-style :membrane.ui/style-stroke
(let [rect (ui/rectangle 100 50)
line (center (ui/path [0 0] [75 0])
(ui/bounds rect))]
[rect line]))
Typically, the graphics for elements and their event handling code is intertwined. These elements are simply views and have no default behavior associated with them. If you do want both the default behavior, check out membrane.component/button
and membrane.component/checkbox
.
(ui/horizontal-layout
(ui/padding 10 10
(ui/checkbox false))
(ui/padding 10 10
(ui/checkbox true)))
(ui/horizontal-layout
(ui/padding 10 10
(ui/button "button"))
(ui/padding 10 10
(ui/button "button" nil true)))
With membrane, events handlers are pure functions that take events and return a sequence of effects. Effects are a data description of what to do rather than a side effect.
;; mouse-down event at location [15 15]
(let [mpos [15 15]]
(ui/mouse-down
(ui/translate 10 10
(ui/on :mouse-down (fn [[mx my]]
;;return a sequence of effects
[[:say-hello]])
(ui/label "Hello")))
mpos))
>> ([:say-hello])
Conceptually, event handling is hierarchical. When an event occurs, it asks the root element what effects should take place. The root element may
The default behavior for most container components is #2, delegating to their child components. However, the parent component always has the final say over what effects should occur over its child components. This is the functional equivalent of the browser's event.preventDefault
and event.stopPropagation
.
Here are few illustrative examples:
(let [elem (ui/on
;; return nil for any mouse-down event
:mouse-down (fn [_] nil)
(ui/button "Big Red Button"
(fn []
[[:self-destruct!]])))]
(ui/mouse-down elem [20 20]))
>>> ()
It's useful for graphical GUI builders and testing to be able to silence components. Use membrane.ui/no-events
to silence all child elems.
(let [elem (ui/no-events
(ui/button "Big Red Button"
(fn []
[[:self-destruct!]])))]
(ui/mouse-down elem [20 20]))
>>> ()
Here, we'll only ask child-elem
what effects should occur if the key pressed is a lower case letter. No symbols, numbers, or upper case letters allowed!
(let [lower-case-letters (set (map str "abcdefghijklmnopqrstuvwxyz"))
child-elem (ui/on :key-press
(fn [s]
[[:child-effect1 s]
[:child-effect2 s]])
(ui/label "child elem"))
elem (ui/on
:key-press (fn [s]
(when (contains? lower-case-letters s)
(ui/key-press child-elem s)))
child-elem)]
{"a" (ui/key-press elem "a")
"." (ui/key-press elem ".")})
>>> {"a" [[:child-effect1 "a"] [:child-effect2 "a"]],
"." nil}
(let [child-elem (ui/on :key-press
(fn [s]
[[:child-effect1 s]
[:child-effect2 s]])
(ui/label "child elem"))
elem (ui/on
:key-press (fn [s]
(if (= s ".")
[[:do-something-special]]
(ui/key-press child-elem s)
))
child-elem)]
{"a" (ui/key-press elem "a")
"." (ui/key-press elem ".")})
>>> {"a" [[:child-effect1 "a"] [:child-effect2 "a"]],
"." [[:do-something-special]]}
Event handlers can be provided using membrane.ui/on
.
:mouse-down [[mx my]]
mpos is a vector of [mx, my]
in the elements local coordinates.
Will only be called if [mx my] is within the element's bounds
(on :mouse-down (fn [[mx my :as mpos]]
;;return a sequence of effects
[[:hello mx my]])
(ui/label "Hello"))
:mouse-up [[mx my]]
mpos is a vector of [mx, my]
in the elements local coordinates.
Will only be called if [mx my] is within the element's bounds
(on :mouse-up (fn [[mx my :as mpos]]
;;return a sequence of effects
[[:hello mx my]])
(ui/label "Hello"))
:mouse-move [[mx my]]
mpos is a vector of [mx, my]
in the elements local coordinates.
Will only be called if [mx my] is within the element's bounds
(on :mouse-move (fn [[mx my :as mpos]]
;;return a sequence of effects
[[:hello mx my]])
(ui/label "Hello"))
:mouse-event [mpos button mouse-down? mods]
mpos
is a vector of [mx, my]
in the elements local coordinates.
button
is 0 if left click, 1 if right click. greater than 1 for more exotic mouse buttons
mouse-down?
is true if button is pressed down, false if the button is being released
mods
is an integer mask. masks are
SHIFT 0x0001
CONTROL 0x0002
ALT 0x0004
SUPER 0x0008
CAPS_LOCK 0x0010
NUM_LOCK 0x0020
Will only be called if [mx my]
is within the element's bounds
(on :mouse-event (fn [[mpos button mouse-down? mods]]
;;return a sequence of effects
[[:hello mx my]])
(ui/label "Hello"))
:key-event [key scancode action mods]
key
is a string if it is printable
otherwise one of
:grave_accent :world_1 :world_2 :escape :enter :backspace :insert :delete :right :left :down :up :page_up :page_down :home :end :caps_lock :scroll_lock :num_lock :print_screen :pause :f1 :f2 :f3 :f4 :f5 :f6 :f7 :f8 :f9 :f10 :f11 :f12 :f13 :f14 :f15 :f16 :f17 :f18 :f19 :f20 :f21 :f22 :f23 :f24 :f25 :kp_0 :kp_1 :kp_2 :kp_3 :kp_4 :kp_5 :kp_6 :kp_7 :kp_8 :kp_9 :kp_decimal :kp_divide :kp_multiply :kp_subtract :kp_add :kp_enter :kp_equal :left_shift :left_control :left_alt :left_super :right_shift :right_control :right_alt :right_super :menu
scancode
The scancode is unique for every key, regardless of whether it has a key token. Scancodes are platform-specific but consistent over time, so keys will have different scancodes depending on the platform but they are safe to save to disk.
action
one of :press, :repeat, :release, or :unknown if the underlying platform documentation has lied.
mods
is an integer mask. masks are
SHIFT 0x0001 CONTROL 0x0002 ALT 0x0004 SUPER 0x0008 CAPS LOCK 0x0010 NUM LOCK 0x0020
(on :key-event (fn [key scancode action mods]
[[:hello key scancode action mods]])
(ui/label "Hello"))
:key-press [key]
key
is a string if it is printable
otherwise one of
:grave_accent :world_1 :world_2 :escape :enter :backspace :insert :delete :right :left :down :up :page_up :page_down :home :end :caps_lock :scroll_lock :num_lock :print_screen :pause :f1 :f2 :f3 :f4 :f5 :f6 :f7 :f8 :f9 :f10 :f11 :f12 :f13 :f14 :f15 :f16 :f17 :f18 :f19 :f20 :f21 :f22 :f23 :f24 :f25 :kp_0 :kp_1 :kp_2 :kp_3 :kp_4 :kp_5 :kp_6 :kp_7 :kp_8 :kp_9 :kp_decimal :kp_divide :kp_multiply :kp_subtract :kp_add :kp_enter :kp_equal :left_shift :left_control :left_alt :left_super :right_shift :right_control :right_alt :right_super :menu
(on :key-press (fn [key] [[:respond-to-key key]]) (ui/label "Hello"))
:clipboard-copy [] Called when a clipboard copy event occurs.
:clipboard-cut Called when a cliboard cut event occurs.
:clipboard-paste [s]
s
is the string being pasted
Called when a clipboard paste event occurs.
Another benefit of having a value based event system is that you can also easily transform and filter effects.
(defn search-bar [s]
(horizontal-layout
(on
:mouse-down (fn [_]
[[:search s]])
(ui/button "Search"))
(ui/label s)))
(let [selected-search-type :full-text
bar (search-bar "clojure")
elem (on :search
(fn [s]
[[:search selected-search-type s]])
bar)]
(ui/mouse-down elem
[10 10]))
>>> ([:search :full-text "clojure"])
The effects returned by event handlers are meant to be used in conjuction with UI frameworks. If you're making a simple UI, then you can just put your side effects in the event handler. Just note that you'll want to return nil from the handler since the event machinery expects a sequence of events. Nothing bad will happen if you don't, but you may see IllegalArgumentExceptions in the logs with the message "Don't know how to create ISeq from: ...".
(ui/translate 10 10
(on :mouse-down (fn [[mx my]]
;; side effect
(println "hi" mx my)
;; return nil to prevent error messages
nil)
(ui/label "Hello")))
While using membrane does not require any specific UI framework, it does provide its own builtin UI framework, membrane.component
.
The purpose of a UI framework is to provide tools to handle events and manage state. If the job was simply visualizing data, then all you would need is a function that accepts data and returns what to display. It's the interactivity that makes things more complicated.
For an interactive interface, you want to combine what to draw with how to respond to events.
To explain how membrane.component
helps, let's a take a simple example of creating an ugly checkbox.
Without using any framework, your code might look something like this:
(def app-state (atom false))
(defn checkbox [checked?]
(on
:mouse-down
(fn [_]
(swap! app-state not)
nil)
(ui/label (if checked?
"X"
"O"))))
(comment (skia/run #(checkbox @app-state)))
This works great for this simple example, but this checkbox has to know exactly how to update the checked?
value. Changing the data model would require changing the checkbox code and we would really like our checkbox to be more reusable.
Let's see what this same ugly checkbox would look like with membrane.component
(defui checkbox [& {:keys [checked?]}]
(on
:mouse-down
(fn [_]
[[::toggle $checked?]])
(ui/label (if checked?
"X"
"O"))))
(defeffect ::toggle [$checked?]
(dispatch! :update $checked? not))
(defui checkbox-test [& {:keys [x y z]}]
(vertical-layout
(checkbox :checked? x)
(checkbox :checked? y)
(checkbox :checked? z)
(ui/label
(with-out-str (clojure.pprint/pprint
{:x x
:y y
:z z})))))
(comment (skia/run (component/make-app #'checkbox-test {:x false :y true :z false})))
Here's what the above looks like
You'll notice a few differences in the code.
defn
s have been replaced with defui
s.$checked?
::toggle
effect defined by defeffect
The most interesting part of this example is the :mouse-down
event handler which returns [[::toggle $checked?]]
. Loosely translated, this means "when a :mouse-down
event occurs, the checkbox proposes 1 effect which toggles the value of checked?
." It does not specify how the ::toggle
effect is implemented.
What exactly is $checked?
? The symbol $checked?
is replaced by the defui
macro with a value that specifies the path to checked?
. In fact, the defui
macro will replace all symbols that start with "$" that derive from a keyword parameter with a value that represents the path of the corresponding symbol.
You can find the implementation of the ::toggle
effect by checking its defeffect
.
(defeffect ::toggle [$checked?]
(dispatch! :update $checked? not))
membrane.component/defeffect: [type args & body]
is a macro that does 3 things:
type
(in this case, ::toggle
). type
should be a keyword and since it is registered globally, should be namespacedeffect-*type*
where type is the name of the type keyword (in this example, "toggle"). This can be useful if you want to be able to use your effect functions separately from the ui framework (for example, testing).dispatch!
The arglist for dispatch!
is [type & args]
. Calling dispatch!
will invoke the effect of type
with args
.
The role of dispatch!
is to allow effects to define themselves in terms of other effects. Effects should not be called directly because while the default for an application is to use all the globally defined effects, this can be overridden for testing, development, or otherwise.
Every component can be run on its own. The goal is to build complex components and applications out of simpler components.
To run a component, simply call your implementation's run
function with (component/make-app #'component-var initial-state)
For this example, we'll build a file selector. Usage will be as follows: Call (file-selector "/path/to/folder")
to open up a window with a file selector. The selected filenames will be returned as a set when the window is closed.
First, we'll build a generic item selector. Our item selector will display a row of checkboxes and filenames.
Let's create the component to display and select individual items.
(defui item-row [ & {:keys [item-name selected?]}]
(on
:mouse-down
(fn [_]
[[:update $selected? not]])
;; put the items side by side
(horizontal-layout
(translate 5 5
;; checkbox in `membrane.ui` is non interactive.
(ui/checkbox selected?))
(spacer 5 0)
(ui/label item-name))))
(comment
;; It's a very common workflow to work on sub components one piece at a time.
(skia/run (component/make-app #'item-row {:item-name "my item" :selected? false})))
Next, we'll build a generic item selector. For our item selector, we'll have a vertical list of items. Additionally, we'll have a textarea that let's us filter for only names that have the textarea's substring.
(defui item-selector
"`item-names` a vector of choices to select from
`selected` a set of selected items
`str-filter` filter out item names that don't contain a case insensitive match for `str-filter` as a substring
"
[& {:keys [item-names selected str-filter]
:or {str-filter ""
selected #{}}}]
(let [filtered-items (filter #(clojure.string/includes? (clojure.string/lower-case %) str-filter) item-names)]
(apply
vertical-layout
(basic/textarea :text str-filter)
(for [iname filtered-items]
;; override the default behaviour of updating the `selected?` value directly
;; instead, we'll keep the list of selected items in a set
(on :update
(fn [& args]
[[:update $selected (fn [selected]
(if (contains? selected iname)
(disj selected iname)
(conj selected iname)))]])
(item-row :item-name iname :selected? (get selected iname)))))))
(comment
(skia/run (component/make-app #'item-selector {:item-names (->> (clojure.java.io/file ".")
(.listFiles)
(map #(.getName %)))} )))
Finally, we'll define a file-selector function using our item-selector
ui.
(defn file-selector [path]
(let [state (atom {:item-names
(->> (clojure.java.io/file path)
(.listFiles)
(map #(.getName %))
sort)})]
(skia/run-sync (component/make-app #'item-selector state))
(:selected @state)))
Since run-sync
will wait until the window is closed, all we need to do is dereference the state atom and grab the value for the :selected
key.
Can you improve this documentation?Edit on GitHub
cljdoc is a website building & hosting documentation for Clojure/Script libraries
× close