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

Various UI widget questions #62

Closed mperham closed 7 months ago

mperham commented 7 months ago

Hey Andy, nice meeting you and chatting at length the other night. I'm attempting a spike of the Sidekiq Web UI written in glimmer-dsl-libui but I'm struggling to find documentation on the various components and their properties available.

Here's the Web UI:

Screenshot 2023-11-17 at 12 13 43 PM

There's four main vertical pieces:

  1. Navbar
  2. Global stats
  3. Current tab content
  4. Footer

I'm not tied to the web layout, it may not be appropriate for a desktop app, so "outside of the box" design ideas are welcome.

mperham commented 7 months ago

BTW, the project is at https://github.com/mperham/quick.

AndyObtiva commented 7 months ago

It was nice to meet you too and chat at RubyConf 2023. I'll take a look at your code and get back to you with answers.

AndyObtiva commented 7 months ago

Below is a general implementation example that follows the Glimmer application conventions (using Glimmer::LibUI::Application for the main app class) and divides the GUI into multiple View components (using Glimmer::LibUI::CustomControl mixin).

Screenshot 2023-11-17 at 8 02 26 PM

Here is an Animated Gif:

admin-ui1

Note that the example implementation currently generates its own fake data. Also, the graph can be improved upon by adding background line patterns, and by not rendering any points that are not visible to speed up performance.

You can divide the GUI the same way you think about it. This is how I divided the GUI:

Typically, we use labels to display information unless there is a need to add a font/color, in which case, we could use text under the area control to do so. Also, desktop apps usually have simpler platform conforming user interfaces, so over-customization of look and feel is avoided in favor of a more native and simple look and feel.

Ruby code:

require 'glimmer-dsl-libui'

# Models

class Job
  STATUSES = %i[processed failed busy enqueued retried scheduled dead]

  attr_accessor :id, :status, :time
end

class JobManager
  attr_accessor :jobs, :polling_interval
  attr_reader :version, :redis_url, :time, :docs_url, :locale

  def initialize
    @initialize_time = Time.now
    @jobs = []
    @polling_interval = 1
    @version = '7.2.0'
    @redis_url = 'redis://localhost:6379/0'
    @time = Time.now.utc
    @docs_url = 'https://github.com/mperham/sidekiq/wiki'
    @locale = 'en'
  end

  def job_count_for_status(status)
    jobs.select {|job| job.status == status.to_s.to_sym}.size
  end

  def report_points
    points = []
    current_jobs = jobs.dup
    start_time = @initialize_time
    end_time = Time.now
    time_length = (end_time - start_time).to_i
    time_length.times do |n|
      job_found = current_jobs.detect do |job|
        job_delay = job.time - start_time
        job_delay.between?(n, n + 1)
      end
      x = n*15
      y = job_found ? 5 : 195
      points << [x, y]
    end
    translate_points(points)
    points
  end

  def translate_points(points)
    max_job_count_before_translation = ((800/15).to_i + 1)
    x_translation = [(points.size - max_job_count_before_translation)*15, 0].max
    if x_translation > 0
      points.each do |point|
        point[0] = point[0] - x_translation
      end
    end
  end
end

# Views (Components)

class GlobalStat
  include Glimmer::LibUI::CustomControl

  option :job_manager
  option :job_status

  body {
   vertical_box {
     label {
       text <= [job_manager, :jobs,
                 on_read: ->(jobs_array) {
                   job_manager.job_count_for_status(job_status).to_s
                 }
               ]
     }

     label(job_status.to_s.capitalize)
   }
  }
end

class GlobalStats
  include Glimmer::LibUI::CustomControl

  option :job_manager

  body {
    horizontal_box {
      stretchy false

      Job::STATUSES.each do |job_status|
        global_stat(job_manager: job_manager, job_status: job_status)
      end
    }
  }
end

class DashboardGraph
  include Glimmer::LibUI::CustomControl

  option :job_manager

  after_body do
    polling_interval = job_manager.polling_interval
    time_remaining = job_manager.polling_interval
    timer_interval = 1 # 1 second
    Glimmer::LibUI.timer(timer_interval) do
      if polling_interval != job_manager.polling_interval
        if job_manager.polling_interval < polling_interval
          time_remaining = job_manager.polling_interval
        else
          time_remaining += job_manager.polling_interval - polling_interval
        end
        polling_interval = job_manager.polling_interval
      end
      time_remaining -= timer_interval
      if time_remaining == 0
        body_root.queue_redraw_all
        time_remaining = job_manager.polling_interval
      end
    end
  end

  body {
    area {
      stretchy false

      rectangle(0, 0, 800, 200) {
        fill 255, 255, 255
      }

      on_draw do
        last_point = nil
        job_manager.report_points.each do |point|
          circle(point.first, point.last, 3) {
            fill 0, 128, 0
          }
          if last_point
            line(last_point.first, last_point.last, point.first, point.last) {
              stroke 0, 128, 0, thickness: 2
            }
          end
          last_point = point
        end
      end
    }
  }
end

class Dashboard
  include Glimmer::LibUI::CustomControl

  option :job_manager

  body {
    vertical_box {
      global_stats(job_manager: job_manager)

      horizontal_box {
        label('Dashboard') {
          stretchy false
        }

        # filler
        label

        vertical_box {
          horizontal_box {
            label('Polling interval:') {
              stretchy false
            }

            label {
              text <= [job_manager, :polling_interval,
                        on_read: ->(val) { "#{val} sec" }
                      ]
            }
          }

          slider(1, 10) {
            value <=> [job_manager, :polling_interval]
          }
        }
      }

      dashboard_graph(job_manager: job_manager)

      status_bar(job_manager: job_manager)
    }
  }
end

class StatusBar
  include Glimmer::LibUI::CustomControl

  option :job_manager

  before_body do
    @text_font_family = (OS.mac? ? 'Helvetica' : 'Arial')
    @text_font_size = (OS.mac? ? 14 : 11)
    @text_font = {family: @text_font_family, size: @text_font_size}
    @text_color = :grey
    @background_color = :black
  end

  body {
    area {
      rectangle(0, 0, 800, 30) {
        fill @background_color
      }

      text(20, 5, 100) {
        string("Sidekiq v#{job_manager.version}") {
          font @text_font
          color @text_color
        }
      }

      text(120, 5, 160) {
        string(job_manager.redis_url) {
          font @text_font
          color @text_color
        }
      }

      text(280, 5, 100) {
        string(job_manager.time.strftime('%T UTC')) {
          font @text_font
          color @text_color
        }
      }

      text(380, 5, 100) {
        string("docs #{job_manager.locale}") {
          font @text_font
          color :red
        }

        on_mouse_up do
          system "open #{job_manager.docs_url}"
        end
      }
    }
  }
end

# Main Application View

class AdminUI
  include Glimmer::LibUI::Application

  before_body do
    @job_manager = JobManager.new
  end

  after_body do
    generate_jobs
  end

  body {
    window('Sidekiq UI', 800, 450) {
      vertical_box {
        tab {
          tab_item('Dashboard') {
            dashboard(job_manager: @job_manager)
          }
          tab_item('Busy') {
          }
          tab_item('Queues') {
          }
          tab_item('Retries') {
          }
          tab_item('Scheduled') {
          }
          tab_item('Dead') {
          }
          tab_item('Metrics') {
          }
        }
      }
    }
  }

  def generate_jobs
    Glimmer::LibUI.timer(1) do
      if rand(5) == 0
        job = Job.new
        job.time = Time.now
        job.id = job.time.to_f*10000000
        job.status = Job::STATUSES.sample
        @job_manager.jobs << job
      end
    end
  end
end

AdminUI.launch

You can save in a file called admin_ui.rb and run via glimmer admin_ui.rb

Please make sure to place each class in a separate file when adapting this code to your application.

If you encounter issues or think of other questions, I'd be happy to help you further with this by submitting Pull Requests to your repo. I just thought I'd give you a chance to learn from the code example I gave you first.

P.S. LibUI is still alpha, so it suffers from layout issues when including multiple area controls in the user interface, and it does not support customization of colors/fonts on label controls. It also has slow performance when rendering canvas graphics, albeit that is currently being worked on by replacing FFI with Native C Extensions for canvas graphic rendering. These issues/limitations should get addressed in the future.

In the meantime, if there is a need for something more robust and mature, Glimmer DSL for SWT supports a mature GUI toolkit called Eclipse SWT with the ability to package apps as native EXE/MSI/APP/PKG/DMG/DEB/RPM files. The only caveat is it requires JRuby instead of CRuby, but that is hidden from the user if the app is packaged natively. But, of course, CRuby offers the benefit of instant startup time (vs about 2.5 seconds with JRuby on Mac M2) as it does NOT need to load Java like JRuby does. That is why I maintain Glimmer DSL for LibUI with the hope that LibUI would catch up with SWT eventually as these two toolkits support native controls on all platforms. Glimmer DSL for WX also supports native controls and is the newest Glimmer GUI library, but the wxWidgets toolkit does not get installed quickly via binaries like LibUI, yet requires native compilation, which takes a while. I should be expanding Glimmer DSL for WX support further in the future too.

mperham commented 7 months ago

Whew, Andy, that's really generous. Thank you for the time and your expertise!

AndyObtiva commented 7 months ago

One other thing I forgot to mention is you need to install the latest glimmer-dsl-libui (or newer when new versions come out) to make the code example that I shared work. I noticed you had a very old version in your branch due to not specifying a version.

To install manually:

gem install glimmer-dsl-libui -v0.11.3

When using Bundler, ensure Gemfile has the following:

gem 'glimmer-dsl-libui', '0.11.3'

Or this if you want to upgrade when related patch versions are available:

gem 'glimmer-dsl-libui', '~> 0.11.3'

There is an outstanding bug in 0.11.3 that I will fix soon in relation to content data-binding (a newly added feature), but it should work with the code I shared as it is not impacted by the bug.