AndyObtiva / glimmer-dsl-libui

Glimmer DSL for LibUI - Prerequisite-Free Ruby Desktop Development Cross-Platform Native GUI Library - The Quickest Way From Zero To GUI - If You Liked Shoes, You'll Love Glimmer! - No need to pre-install any prerequisites. Just install the gem and have platform-independent GUI that just works on Mac, Windows, and Linux.
MIT License
458 stars 15 forks source link

Form table example: two small suggestions #28

Closed rubyFeedback closed 1 year ago

rubyFeedback commented 2 years ago

1) Would it be possible to dump the current dataset into a .yml file and/or hash? This can happen on the commandline too. That way we can quickly copy/paste it. Or perhaps a .yml file, save it into a local file, so we can use that as a real contact form.

2) Would it be possible to also delete a row? Not sure how easy it is to do so; could simply be a button and a field with the field focusing on a number; or more elegantly perhaps the currently selected row will be deleted if a delete-button is clicked.

The example I refer to is the one provided in the main README e.g . with

@contacts = [
      Contact.new('Lisa Sky', 'lisa@sky.com', '720-523-4329', 'Denver', 'CO'),
      Contact.new('Jordan Biggins', 'jordan@biggins.com', '617-528-5399', 'Boston', 'MA'),
      Contact.new('Mary Glass', 'mary@glass.com', '847-589-8788', 'Elk Grove Village', 'IL'),
      Contact.new('Darren McGrath', 'darren@mcgrath.com', '206-539-9283', 'Seattle', 'WA'),
      Contact.new('Melody Hanheimer', 'melody@hanheimer.com', '213-493-8274', 'Los Angeles', 'CA'),
    ]
AndyObtiva commented 1 year ago

I am going to answer 2 first.

2-

Below is a modified form_table.rb example code that supports deletion with an X button (I am using Ruby 3.1 with support for the 3-dot ... operator).

Do not forget that with table data-binding, it is very simple to delete. As long as you delete an element from the data-bound array "self.contacts" (as known from this code cell_rows <=> [self, :contacts]), Glimmer DSL for LibUI will automatically reflect your change in the table control (the view). The code responsible for that is under the on_clicked listener on button_column with the logic self.contacts.delete_at(row) (deletes by clicked row index)

By the way, deleting a table row by selecting/right-clicking a row (as opposed to clicking X) or by checkmarking it does not work today (not on all platforms) due to limitations in LibUI, but I believe libui-ng is working on supporting these options in the future.

require 'glimmer-dsl-libui'

class FormTable
  Contact = Struct.new(:name, :email, :phone, :city, :state, :delete) do
    def initialize(...)
      super(...)
      self.delete = 'X'
    end
  end

  include Glimmer

  attr_accessor :contacts, :name, :email, :phone, :city, :state, :filter_value

  def initialize
    @contacts = [
      Contact.new('Lisa Sky', 'lisa@sky.com', '720-523-4329', 'Denver', 'CO'),
      Contact.new('Jordan Biggins', 'jordan@biggins.com', '617-528-5399', 'Boston', 'MA'),
      Contact.new('Mary Glass', 'mary@glass.com', '847-589-8788', 'Elk Grove Village', 'IL'),
      Contact.new('Darren McGrath', 'darren@mcgrath.com', '206-539-9283', 'Seattle', 'WA'),
      Contact.new('Melody Hanheimer', 'melody@hanheimer.com', '213-493-8274', 'Los Angeles', 'CA'),
    ]
  end

  def launch
    window('Contacts', 600, 600) {
      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.map(&:to_s).include?('')
              msg_box_error('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')
          button_column('Delete') {
            on_clicked do |row|
              self.contacts.delete_at(row)
            end
          }

          editable true
          cell_rows <=> [self, :contacts] # explicit data-binding to self.contacts Model Array, auto-inferring model attribute names from underscored table column names by convention

          on_changed do |row, type, row_data|
            puts "Row #{row} #{type}: #{row_data}"
            $stdout.flush # for Windows
          end

          on_edited do |row, row_data| # only fires on direct table editing
            puts "Row #{row} edited: #{row_data}"
            $stdout.flush # for Windows
          end
        }
      }
    }.show
  end
end

FormTable.new.launch

Screenshots where I click the X buttons one by one in random order.

Screen Shot 2022-07-12 at 9 01 37 PM

Screen Shot 2022-07-12 at 9 01 52 PM

Screen Shot 2022-07-12 at 9 01 59 PM

Screen Shot 2022-07-12 at 9 02 01 PM

Screen Shot 2022-07-12 at 9 02 03 PM

Screen Shot 2022-07-12 at 9 02 04 PM

AndyObtiva commented 1 year ago

Regarding your first question: "Would it be possible to dump the current dataset into a .yml file and/or hash? This can happen on the commandline too. That way we can quickly copy/paste it. Or perhaps a .yml file, save it into a local file, so we can use that as a real contact form."

1-

I am not sure why you are asking me that question since it is a Ruby matter that is not related to GUI or Glimmer DSL for LibUI. So, you should know the answer already.

At the start of the program, use the YAML class to load data from a .yaml/.yml file (alternatively use the CSV class to load data from a .csv file, use JSON class to load data from a .json file, or use the File class to load a flat file), and you're set!

It is recommended you store/load attributes as a hash that can be used to construct Contacts. Otherwise, YAML will complain about classes not being permitted unless you whitelist them, which adds unnecessary complexity.

Also, you have to save back to the YAML (or whatever format) file upon every change the user makes. You can use the table on_changed listener for that purpose.

In any case, below is an implementation that loads/stores contacts from/to a YAML file via contact attribute hashes.

Start by creating this directory: ~/.form-table:

mkdir ~/.form-table

Next, create a YAML file at ~/.form-table/contacts.yml:

touch ~/.form-table/contacts.yml

Open the YAML file and add to it content like the following:

---
- :name: Lisa Sky
  :email: lisa@sky.com
  :phone: 720-523-4329
  :city: Denver
  :state: CO
  :delete: X
- :name: Jordan Biggins
  :email: jordan@biggins.com
  :phone: 617-528-5399
  :city: Boston
  :state: MA
  :delete: X
- :name: Mary Glass
  :email: mary@glass.com
  :phone: 847-589-8788
  :city: Elk Grove Village
  :state: IL
  :delete: X
- :name: Darren McGrath
  :email: darren@mcgrath.com
  :phone: 206-539-9283
  :city: Seattle
  :state: WA
  :delete: X
- :name: Melody Hanheimer
  :email: melody@hanheimer.com
  :phone: 213-493-8274
  :city: Los Angeles
  :state: CA
  :delete: X

Finally, run the following modified Form Table code:

require 'glimmer-dsl-libui'
require 'yaml'
require 'fileutils'

Contact = Struct.new(:name, :email, :phone, :city, :state, :delete, keyword_init: true) do
  def initialize(...)
    super(...)
    self.delete = 'X'
  end
end

class FormTable
  CONTACT_FILE = File.join(Dir.home, '.form-table', 'contacts.yml')
  FileUtils.mkdir_p File.dirname(CONTACT_FILE)

  include Glimmer

  attr_accessor :contacts, :name, :email, :phone, :city, :state, :filter_value

  def initialize
    load_contacts
  end

  def load_contacts
    if File.exist?(CONTACT_FILE)
      self.contacts = YAML.load(File.read(CONTACT_FILE)).map { |contact_hash| Contact.new(contact_hash) }
    else
      self.contacts = []
    end
  end

  def save_contacts
    File.write(CONTACT_FILE, YAML.dump(self.contacts.map(&:to_h)))
  end

  def launch
    window('Contacts', 600, 600) {
      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: name, email: email, phone: phone, city: city, state: state}
            if new_row.values.map(&:to_s).include?('')
              msg_box_error('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')
          button_column('Delete') {
            on_clicked do |row|
              self.contacts.delete_at(row)
            end
          }

          editable true
          cell_rows <=> [self, :contacts] # explicit data-binding to self.contacts Model Array, auto-inferring model attribute names from underscored table column names by convention

          on_changed do |row, type, row_data|
            # Ensure that changes to contact attributes results in saving contacts
            save_contacts

            puts "Row #{row} #{type}: #{row_data}"
            $stdout.flush # for Windows
          end

          on_edited do |row, row_data| # only fires on direct table editing
            puts "Row #{row} edited: #{row_data}"
            $stdout.flush # for Windows
          end
        }
      }
    }.show
  end
end

FormTable.new.launch

Now, when you run the app, add new contacts, close it, and open again; it remembers your new contacts or any changes to older contacts, including deletion.