55
Glimmer DSL for LibUI Bidirectional Data-Binding
I am including below an explanation of the Observer Pattern and Data-Binding in Glimmer DSL for LibUI.
Afterwards, I conclude by sharing an example that takes full advantage of the data-binding features in Glimmer DSL for LibUI, a contact management example app called Form Table.
Happy Glimmering!
The Observer Design Pattern (a.k.a. Observer Pattern) is fundamental to building GUIs (Graphical User Interfaces) following the MVC (Model View Controller) Architectural Pattern or any of its variations like MVP (Model View Presenter). In the original Smalltalk-MVC, the View observes the Model for changes and updates itself accordingly.
Object
becomes Glimmer::DataBinding::ObservableModel
, which supports observing specified Object
model attributes.Hash
becomes Glimmer::DataBinding::ObservableHash
, which supports observing all Hash
keys or a specific Hash
keyArray
becomes Glimmer::DataBinding::ObservableArray
, which supports observing Array
changes like those done with push
, <<
, delete
, and map!
methods (all mutation methods).Example:
observe(person, :name) do |new_name|
@name_label.text = new_name
end
That observes a person's name attribute for changes and updates the name
label
text
property accordingly.See examples of the
observe
keyword at Color The Circles, Method-Based Custom Keyword, Snake, and Tetris.Data-binding enables writing very expressive, terse, and declarative code to synchronize View properties with Model attributes without writing many lines or pages of imperative code doing the same thing, increasing productivity immensely.
Data-binding automatically takes advantage of the Observer Pattern behind the scenes and is very well suited to declaring View property data sources piecemeal. On the other hand, explicit use of the Observer Pattern is sometimes more suitable when needing to make multiple View updates upon a single Model attribute change.
Data-binding supports utilizing the MVP (Model View Presenter) flavor of MVC by observing both the View and a Presenter for changes and updating the opposite side upon encountering them. This enables writing more decoupled cleaner code that keeps View code and Model code disentangled and highly maintainable. For example, check out the Snake game presenters for Grid and Cell, which act as proxies for the actual Snake game models Snake and Apple, mediating synchronization of data between them and the Snake View GUI.

checkbox
: checked
check_menu_item
: checked
color_button
: color
combobox
: selected
, selected_item
date_picker
: time
date_time_picker
: time
editable_combobox
: text
entry
: text
font_button
: font
multiline_entry
: text
non_wrapping_multiline_entry
: text
radio_buttons
: selected
radio_menu_item
: checked
search_entry
: text
slider
: value
spinbox
: value
table
: cell_rows
(explicit data-binding by using <=>
and implicit data-binding by assigning value directly)time_picker
: time
Example of bidirectional data-binding:
entry {
text <=> [contract, :legal_text]
}
That is data-binding a contract's legal text to an
entry
text
property.Another example of bidirectional data-binding with an option:
entry {
text <=> [self, :entered_text, after_write: ->(text) {puts text}]
}
That is data-binding
entered_text
attribute on self
to entry
text
property and printing text after write to the model.Example of unidirectional data-binding:
square(0, 0, CELL_SIZE) {
fill <= [@grid.cells[row][column], :color]
}
That is data-binding a grid cell color to a
square
shape's fill
property. That means if the color
attribute of the grid cell is updated, the fill
property of the square
shape is automatically updated accordingly.Another Example of unidirectional data-binding with an option:
window {
title <= [@game, :score, on_read: -> (score) {"Glimmer Snake (Score: #{@game.score})"}]
}
That is data-binding the
window
title
property to the score
attribute of a @game
, but converting on read from the Model to a String
.To summarize the data-binding API:
view_property <=> [model, attribute, *read_or_write_options]
: Bidirectional (two-way) data-binding to Model attribute accessorview_property <= [model, attribute, *read_only_options]
: Unidirectional (one-way) data-binding to Model attribute readerThis is also known as the Glimmer Shine syntax for data-binding, a Glimmer-only unique innovation that takes advantage of Ruby's highly expressive syntax and malleable DSL support.
Data-bound model attribute can be:
Symbol
representing attribute reader/writer (e.g. [person, :name
])String
representing nested attribute path (e.g. [company, 'address.street']
). That results in "nested data-binding"String
containing array attribute index (e.g. [customer, 'addresses[0].street']
). That results in "indexed data-binding"Data-binding options include:
before_read {|value| ...}
: performs an operation before reading data from Model to update the View.on_read {|value| ...}
: converts value read from Model to update the View.after_read {|converted_value| ...}
: performs an operation after read from Model and updating the View.before_write {|value| ...}
: performs an operation before writing data to Model from View.on_write {|value| ...}
: converts value read from View to update the Model.after_write {|converted_value| ...}
: performs an operation after writing to Model from View.computed_by attribute
or computed_by [attribute1, attribute2, ...]
: indicates model attribute is computed from specified attribute(s), thus updated when they are updated (see in Login example version 2). That is known as "computed data-binding".Note that with both
on_read
and on_write
converters, you could pass a Symbol
representing the name of a method on the value object to invoke.Example:
entry {
text <=> [product, :price, on_read: :to_s, on_write: :to_i]
}
Data-binding gotchas:
entry
text
property to self
text
attribute) as it would conflict with it. Instead, data-bind view property to an attribute with a different name on the view object or with the same name, but on a presenter or model object (e.g. data-bind entry
text
to self
legal_text
attribute or to contract
model text
attribute)on_changed
for entry
text
), so you cannot hook into the listener directly anymore as that would negate data-binding. Instead, you can add an after_write: ->(val) {}
option to perform something on trigger of the control listener instead.Learn more from data-binding usage in Login (4 data-binding versions), Basic Entry, Form, Form Table (5 data-binding versions), Method-Based Custom Keyword, Snake and Tic Tac Toe examples.
Run with this command from the root of the project if you cloned the project:
ruby -r './lib/glimmer-dsl-libui' examples/form_table.rb
Run with this command if you installed the Ruby gem:
ruby -r glimmer-dsl-libui -e "require 'examples/form_table'"
Mac | Windows | Linux |
---|---|---|
![]() |
![]() |
![]() |
require 'glimmer-dsl-libui'
class FormTable
Contact = Struct.new(:name, :email, :phone, :city, :state)
include Glimmer
attr_accessor :contacts, :name, :email, :phone, :city, :state, :filter_value
def initialize
@contacts = [
Contact.new('Lisa Sky', '[email protected]', '720-523-4329', 'Denver', 'CO'),
Contact.new('Jordan Biggins', '[email protected]', '617-528-5399', 'Boston', 'MA'),
Contact.new('Mary Glass', '[email protected]', '847-589-8788', 'Elk Grove Village', 'IL'),
Contact.new('Darren McGrath', '[email protected]', '206-539-9283', 'Seattle', 'WA'),
Contact.new('Melody Hanheimer', '[email protected]', '213-493-8274', 'Los Angeles', 'CA'),
]
end
def launch
window('Contacts', 600, 600) { |w|
margined true
vertical_box {
form {
stretchy false
entry {
label 'Name'
text <=> [self, :name] # bidirectional data-binding between entry text and self.name
}
entry {
label 'Email'
text <=> [self, :email]
}
entry {
label 'Phone'
text <=> [self, :phone]
}
entry {
label 'City'
text <=> [self, :city]
}
entry {
label 'State'
text <=> [self, :state]
}
}
button('Save Contact') {
stretchy false
on_clicked do
new_row = [name, email, phone, city, state]
if new_row.include?('')
msg_box_error(w, 'Validation Error!', 'All fields are required! Please make sure to enter a value for all fields.')
else
@contacts << Contact.new(*new_row) # automatically inserts a row into the table due to explicit data-binding
@unfiltered_contacts = @contacts.dup
self.name = '' # automatically clears name entry through explicit data-binding
self.email = ''
self.phone = ''
self.city = ''
self.state = ''
end
end
}
search_entry {
stretchy false
# bidirectional data-binding of text to self.filter_value with after_write option
text <=> [self, :filter_value,
after_write: ->(filter_value) { # execute after write to self.filter_value
@unfiltered_contacts ||= @contacts.dup
# Unfilter first to remove any previous filters
self.contacts = @unfiltered_contacts.dup # affects table indirectly through explicit data-binding
# Now, apply filter if entered
unless filter_value.empty?
self.contacts = @contacts.filter do |contact| # affects table indirectly through explicit data-binding
contact.members.any? do |attribute|
contact[attribute].to_s.downcase.include?(filter_value.downcase)
end
end
end
}
]
}
table {
text_column('Name')
text_column('Email')
text_column('Phone')
text_column('City')
text_column('State')
editable true
# explicit data-binding to Model Array (expects model attribute names to be underscored column names by convention [e.g. :state for State], can be customized with :column_attributes option [e.g. {'State/Province' => :state})
cell_rows <=> [self, :contacts]
on_changed do |row, type, row_data|
puts "Row #{row} #{type}: #{row_data}"
end
}
}
}.show
end
end
FormTable.new.launch
55