MittensUi

A lightweight Ruby GUI toolkit built on top of GTK, inspired by Ruby Shoes. MittensUi wraps the complexity of GTK so you can build desktop apps in Ruby.

We always try to keep up with the latest GTK updates.

Installation

Add to your Gemfile:

gem 'mittens_ui'

Then run:

bundle install

Or install directly:

gem install mittens_ui

Quick Start

require 'mittens_ui'

MittensUi::Application.Window(name: "my_app", title: "Hello World", width: 400, height: 300) do
  MittensUi::Label.new("What is your name?")
  name_tb = MittensUi::Textbox.new(can_edit: true, placeholder: "Enter name...")
  btn = MittensUi::Button.new(title: "Say Hello")
  btn.click do
    MittensUi::Alert.new("Hello, #{name_tb.text}!")
  end
end

Core Concepts

Application Window

Every MittensUi app starts with Application.Window. All widgets are created inside the block.

MittensUi::Application.Window(
  name:       "my_app",   # used as process name and store identifier
  title:      "My App",   # window title bar text
  width:      400,        # window width in pixels
  height:     600,        # window height in pixels
  can_resize: true,       # whether the window is resizable
  icon:       "icon.png"  # optional path to a window icon
) do
  # widgets go here
end

Layout System

MittensUi uses a 12-unit grid layout. Every widget accepts a :width option that controls how many columns it occupies.

MittensUi::Label.new("Full width",    width: :full)    # 12 units
MittensUi::Label.new("Half width",    width: :half)    # 6 units
MittensUi::Label.new("Third width",   width: :third)   # 4 units
MittensUi::Label.new("Quarter width", width: :quarter) # 3 units

Two :half widgets placed back to back sit side by side automatically. A :full widget always gets its own row.

Horizontal Rows

Use HBox to place widgets side by side in a row:

MittensUi::HBox.new(spacing: 8) do
  MittensUi::Label.new("Name:")
  MittensUi::Textbox.new(can_edit: true)
  MittensUi::Button.new(title: "Save")
end

HBox supports nesting:

MittensUi::HBox.new(spacing: 8) do
  MittensUi::Label.new("Left")
  MittensUi::HBox.new(spacing: 4) do
    MittensUi::Button.new(title: "A")
    MittensUi::Button.new(title: "B")
  end
  MittensUi::Label.new("Right")
end

Persistent Store

MittensUi includes a built-in key-value store backed by JSON, saved to ~/.local/share/mittens_ui/<app_name>.json. Data persists across app launches automatically.

MittensUi::Application.store.set(:theme, "dark")
MittensUi::Application.store.get(:theme)        # => "dark"
MittensUi::Application.store.get(:missing, 42)  # => 42 (default)
MittensUi::Application.store.delete(:theme)
MittensUi::Application.store.all                # => { theme: "dark" }
MittensUi::Application.store.clear

Widget Reference

Knob

A rotary knob widget inspired by synthesizer hardware. Click and drag up or right to increase the value, down or left to decrease it. Scroll wheel also works.

knob = MittensUi::Knob.new(min: 0, max: 100, value: 50, label: "Volume")
knob.on_change { |v| puts "Volume: #{v}" }

# custom color and size
knob = MittensUi::Knob.new(
  min:   0,
  max:   127,
  value: 64,
  size:  80,
  label: "Cutoff",
  color: [0.2, 0.6, 1.0]
)

# programmatic control
knob.value = 75
puts knob.value  # => 75.0

# row of synth knobs
MittensUi::HBox.new(spacing: 8) do
  MittensUi::Knob.new(min: 0, max: 127, value: 64, label: "Cutoff",    color: [0.2, 0.6, 1.0])
  MittensUi::Knob.new(min: 0, max: 127, value: 32, label: "Resonance", color: [1.0, 0.4, 0.2])
  MittensUi::Knob.new(min: 0, max: 127, value: 80, label: "Attack",    color: [0.8, 0.8, 0.2])
  MittensUi::Knob.new(min: 0, max: 127, value: 60, label: "Release",   color: [0.6, 0.2, 1.0])
end

Label

MittensUi::Label.new("Hello World", top: 10)

Button

btn = MittensUi::Button.new(title: "Click Me")
btn.click { puts "clicked!" }

Buttons support a loading state that disables the button and shows a spinner while background work runs:

btn.click do
  btn.loading do
    sleep 2  # runs on a background thread
    puts "done!"
  end
end

Textbox

# single line
tb = MittensUi::Textbox.new(can_edit: true, placeholder: "Enter text...")
puts tb.text
tb.clear

# password field
tb = MittensUi::Textbox.new(password: true)

# multiline text area
tb = MittensUi::Textbox.new(multiline: true, height: 120)

# with autocomplete suggestions
tb = MittensUi::Textbox.new(can_edit: true)
tb.enable_text_completion(["Ruby", "Rails", "Rack"])

Checkbox

cb = MittensUi::Checkbox.new(label: "Enable notifications")
cb.value = "notifications"
cb.toggle { puts "toggled! value: #{cb.value}" }

RadioButton

A group of mutually exclusive options. Only one can be selected at a time.

rb = MittensUi::RadioButton.new(
  options: ["Small", "Medium", "Large"],
  default: "Medium",
  layout:  :horizontal  # or :vertical
)
puts rb.selected               # => "Medium"
rb.on_change { |v| puts v }   # fires on selection change
rb.select("Large")             # programmatic selection

Listbox

# basic dropdown
lb = MittensUi::Listbox.new(items: ["Ruby", "Python", "Elixir"])
puts lb.selected_value

# with search box
lb = MittensUi::Listbox.new(items: ["Ruby", "Python", "Elixir"], searchable: true)
lb.update_items(["Go", "Rust", "Zig"])
lb.clear_search

Slider

s = MittensUi::Slider.new(start_value: 0, stop_value: 100, initial_value: 50)
s.slide { |widget, value| puts "#{widget}: #{value}" }

Switch

sw = MittensUi::Switch.new
sw.on { puts "status: #{sw.status}" }
puts sw.status  # => :on or :off

Image

img = MittensUi::Image.new("./assets/logo.png", width: 200, height: 200)
img.click { puts "image clicked!" }

# GIF support
img = MittensUi::Image.new("./assets/animation.gif")

TableView

table = MittensUi::TableView.new(
  ['Name', 'Email'],
  [['John', 'john@example.com']]
)

table.add(['Jane', 'jane@example.com'])

table.row_clicked { |row| puts row.inspect }
table.row_double_clicked { |row| puts "Double: #{row.inspect}" }

Alert

MittensUi::Alert.new("Something happened!", title: "Notice")

Notify

MittensUi::Notify.new("Saved!", type: :info)      # banner notification
# types: :info, :error, :question

Loader

loader = MittensUi::Loader.new
loader.start do
  sleep 3  # runs on a background thread
end

HeaderBar

btn = MittensUi::Button.new(title: "New", defer_render: true)
MittensUi::HeaderBar.new([btn], title: "My App", position: :left)
# position: :left or :right

FileMenu

menus = {
  "File": { sub_menus: ["New", "Open", "Exit"] },
  "Edit": { sub_menus: ["Copy", "Paste"] }
}.freeze

fm = MittensUi::FileMenu.new(menus)
fm.exit { MittensUi::Application.exit }
fm.new  { puts "New file" }

FilePicker

picker = MittensUi::FilePicker.new
puts picker.path  # => "/home/user/file.txt" or nil if cancelled
MittensUi::WebLink.new("GitHub", "https://github.com")

Separator

MittensUi::Separator.new
MittensUi::Separator.new(:horizontal, top: 10, bottom: 10)  # with margin

HBox

# block style
MittensUi::HBox.new(spacing: 8) do
  MittensUi::Button.new(title: "OK")
  MittensUi::Button.new(title: "Cancel")
end

# nested HBox
MittensUi::HBox.new(spacing: 8) do
  MittensUi::Label.new("Tools:")
  MittensUi::HBox.new(spacing: 4) do
    MittensUi::Button.new(title: "Cut")
    MittensUi::Button.new(title: "Copy")
    MittensUi::Button.new(title: "Paste")
  end
end

# array style — requires `defer_render: true` attribute to be set on each widget
MittensUi::HBox.new([
  MittensUi::Button.new(title: "OK",     defer_render: true),
  MittensUi::Button.new(title: "Cancel", defer_render: true)
])

Development

MittensUi requires GTK native libraries to be installed on your system.

Install Ruby if you have not done so:

rbenv install 4.0.0
rbenv global 4.0.0

Linux

git clone https://github.com/tuttza/mittens_ui.git
cd mittens_ui
bundle install

macOS

brew install gtk4 cairo pkg-config rbenv

git clone https://codeberg.com/tuttza/mittens_ui.git
cd mittens_ui
bundle install

Run the test suite:

bundle exec rspec

Generate API docs:

bundle exec yard doc

License

Available under the MIT License.