twiddle 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +91 -0
- data/Rakefile +9 -0
- data/docs/input-matrix.md +12 -0
- data/lib/twiddle/context.rb +3 -0
- data/lib/twiddle/id_stack.rb +3 -0
- data/lib/twiddle/input.rb +3 -0
- data/lib/twiddle/rbgl.rb +31 -0
- data/lib/twiddle/version.rb +5 -0
- data/lib/twiddle.rb +628 -0
- data/sig/twiddle.rbs +4 -0
- metadata +82 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 70221dcc0a8cf5d49271d1798e1f77dabdf00e30af8f82c6c2580ce4fead30d2
|
|
4
|
+
data.tar.gz: 3b5c4ffd6ce9cb80d39e3fcf662ab163207e6b8b759d9d5c62d8a440b56f02d9
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: ce7b02fb4c8172da730761827cec5290a8764c1a5f5c7c059d663d34747028303b3a25cda2895a7ffedea1c96a225bc6e9b4ee459a4c8f11f37db589fa342e5b
|
|
7
|
+
data.tar.gz: ba90b1d9e2c3e9581d1489d6bc14d4e55bbfb0ccd9a0942f8fa73fa21666c174a574f711a75c5333d0dbccdd129e2419032ca7e4eafdf011be87d629b73a21a3
|
data/CHANGELOG.md
ADDED
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ydah
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# Twiddle
|
|
2
|
+
|
|
3
|
+
[](https://rubygems.org/gems/twiddle)
|
|
4
|
+
[](https://rubygems.org/gems/twiddle)
|
|
5
|
+
[](https://github.com/rbgfx/twiddle/actions/workflows/ci.yml)
|
|
6
|
+
[](https://www.ruby-lang.org/)
|
|
7
|
+
[](LICENSE.txt)
|
|
8
|
+
|
|
9
|
+
> A headless-friendly immediate-mode UI toolkit for Ruby graphics.
|
|
10
|
+
|
|
11
|
+
Twiddle renders compact controls into Tessel images and accepts plain event
|
|
12
|
+
hashes, so the same UI can run in tests, a headless renderer, or an rbgl
|
|
13
|
+
window.
|
|
14
|
+
|
|
15
|
+
**[Features](#features) · [Installation](#installation) · [Quick start](#quick-start) · [RBGL integration](#rbgl-integration) · [Development](#development)**
|
|
16
|
+
|
|
17
|
+
## Features
|
|
18
|
+
|
|
19
|
+
- Buttons, checkboxes, radios, sliders, drags, text fields, and color editors.
|
|
20
|
+
- Progress bars, plots, collapsing headers, tooltips, and scrolling windows.
|
|
21
|
+
- Deterministic widget IDs with duplicate detection in debug mode.
|
|
22
|
+
- Logical coordinates with configurable rendering and input scale.
|
|
23
|
+
- Keyboard text input, mouse events, wheel scrolling, and modifier keys.
|
|
24
|
+
- Pure image rendering with an optional RBGL event and presentation adapter.
|
|
25
|
+
|
|
26
|
+
## Installation
|
|
27
|
+
|
|
28
|
+
Add Twiddle to your Gemfile:
|
|
29
|
+
|
|
30
|
+
~~~ruby
|
|
31
|
+
gem "twiddle"
|
|
32
|
+
~~~
|
|
33
|
+
|
|
34
|
+
Then run:
|
|
35
|
+
|
|
36
|
+
~~~sh
|
|
37
|
+
bundle install
|
|
38
|
+
~~~
|
|
39
|
+
|
|
40
|
+
Or install the released gem:
|
|
41
|
+
|
|
42
|
+
~~~sh
|
|
43
|
+
gem install twiddle
|
|
44
|
+
~~~
|
|
45
|
+
|
|
46
|
+
## Quick start
|
|
47
|
+
|
|
48
|
+
~~~ruby
|
|
49
|
+
require "tessel"
|
|
50
|
+
require "twiddle"
|
|
51
|
+
|
|
52
|
+
image = Tessel::Image.new(480, 320, fill: "#101827")
|
|
53
|
+
ui = Twiddle::Context.new
|
|
54
|
+
|
|
55
|
+
ui.frame(events: []) do |g|
|
|
56
|
+
g.window("Parameters") do
|
|
57
|
+
g.slider("speed", 1.0, 0.0..4.0)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
ui.render(image)
|
|
62
|
+
image.write("ui.png")
|
|
63
|
+
~~~
|
|
64
|
+
|
|
65
|
+
Pass <code>delta_time:</code> to <code>frame</code> for tooltip timing and
|
|
66
|
+
provide raw events with types such as <code>:mouse_press</code>,
|
|
67
|
+
<code>:mouse_move</code>, <code>:scroll</code>, and <code>:key_press</code>.
|
|
68
|
+
|
|
69
|
+
## RBGL integration
|
|
70
|
+
|
|
71
|
+
Load the adapter and attach Twiddle to an RBGL window:
|
|
72
|
+
|
|
73
|
+
~~~ruby
|
|
74
|
+
require "twiddle/rbgl"
|
|
75
|
+
|
|
76
|
+
Twiddle::RBGL.attach(window)
|
|
77
|
+
~~~
|
|
78
|
+
|
|
79
|
+
The adapter polls backend events and presents the rendered Tessel image with
|
|
80
|
+
<code>set_pixels</code>.
|
|
81
|
+
|
|
82
|
+
## Development
|
|
83
|
+
|
|
84
|
+
~~~sh
|
|
85
|
+
bundle install
|
|
86
|
+
bundle exec rake verify
|
|
87
|
+
~~~
|
|
88
|
+
|
|
89
|
+
## License
|
|
90
|
+
|
|
91
|
+
[MIT](LICENSE.txt)
|
data/Rakefile
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Input matrix
|
|
2
|
+
|
|
3
|
+
| Backend | Text `:char` | Scroll | Modifiers | Mouse coordinates |
|
|
4
|
+
|---|---|---|---|---|
|
|
5
|
+
| Cocoa | yes | `:scroll` with `dx`/`dy` | yes | top-left after backend conversion |
|
|
6
|
+
| X11 | backend key symbol | backend dependent | backend dependent | top-left |
|
|
7
|
+
| Wayland | backend key symbol | `:mouse_scroll` raw event | key modifiers | top-left |
|
|
8
|
+
| Termvas | printable byte | SGR wheel as `:scroll` | CSI/SGR bits | terminal cell coordinates |
|
|
9
|
+
|
|
10
|
+
Twiddle accepts `:char` when available and reconstructs basic ASCII for key
|
|
11
|
+
symbols. Backends that do not provide a common event shape should be adapted
|
|
12
|
+
by their backend connector before reaching the widget layer.
|
data/lib/twiddle/rbgl.rb
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../twiddle"
|
|
4
|
+
|
|
5
|
+
module Twiddle
|
|
6
|
+
module RBGL
|
|
7
|
+
class Attachment
|
|
8
|
+
attr_reader :ui
|
|
9
|
+
|
|
10
|
+
def initialize(window, font: nil, scale: 1.0)
|
|
11
|
+
@window = window
|
|
12
|
+
@ui = Context.new(font: font, scale: scale)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def frame(events: @window.poll_events_raw)
|
|
16
|
+
@ui.frame(events: events) { |context| yield context }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def render_to(image = nil)
|
|
20
|
+
image ||= Tessel::Image.new(@window.width, @window.height)
|
|
21
|
+
@ui.render(image)
|
|
22
|
+
@window.set_pixels(image.bytes)
|
|
23
|
+
image
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.attach(window, font: nil, scale: 1.0)
|
|
28
|
+
Attachment.new(window, font: font, scale: scale)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
data/lib/twiddle.rb
ADDED
|
@@ -0,0 +1,628 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tessel"
|
|
4
|
+
require "glyphic"
|
|
5
|
+
|
|
6
|
+
require_relative "twiddle/version"
|
|
7
|
+
|
|
8
|
+
module Twiddle
|
|
9
|
+
class Error < StandardError; end
|
|
10
|
+
DEFAULT_THEME = {
|
|
11
|
+
window: [32, 36, 48, 240], title: [54, 60, 78, 255], text: [235, 238, 245, 255],
|
|
12
|
+
button: [62, 70, 90, 255], button_hover: [78, 92, 120, 255], slider: [90, 96, 110, 255],
|
|
13
|
+
accent: [100, 180, 240, 255], input: [24, 28, 38, 255], progress: [90, 180, 120, 255],
|
|
14
|
+
separator: [100, 105, 120, 255], tooltip: [20, 24, 32, 245], plot: [120, 200, 240, 255],
|
|
15
|
+
progress_background: [60, 64, 75, 255]
|
|
16
|
+
}.transform_values(&:freeze).freeze
|
|
17
|
+
|
|
18
|
+
class Input
|
|
19
|
+
attr_reader :x, :y, :scroll_x, :scroll_y, :chars, :modifiers, :keys_pressed
|
|
20
|
+
attr_accessor :mouse_down, :mouse_pressed, :mouse_released
|
|
21
|
+
|
|
22
|
+
def initialize
|
|
23
|
+
reset
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def update(events, scale: 1.0)
|
|
27
|
+
@mouse_pressed = @mouse_released = false
|
|
28
|
+
@scroll_x = @scroll_y = 0
|
|
29
|
+
@chars = +""
|
|
30
|
+
@keys_pressed = []
|
|
31
|
+
events.each do |event|
|
|
32
|
+
@modifiers = event[:modifiers] if event.key?(:modifiers)
|
|
33
|
+
case event[:type].to_sym
|
|
34
|
+
when :mouse_move then @x, @y = logical_point(event, scale)
|
|
35
|
+
when :mouse_press then @x, @y = logical_point(event, scale); @mouse_down, @mouse_pressed = true, true
|
|
36
|
+
when :mouse_release then @x, @y = logical_point(event, scale); @mouse_down, @mouse_released = false, true
|
|
37
|
+
when :scroll then @scroll_x += event[:dx].to_f; @scroll_y += event[:dy].to_f
|
|
38
|
+
when :key_press
|
|
39
|
+
key = event[:key]&.to_s&.to_sym
|
|
40
|
+
@keys_pressed << key if key
|
|
41
|
+
@chars << event[:char].to_s if event[:char]
|
|
42
|
+
@chars << ascii_for(key) if !event[:char] && ascii_for(key)
|
|
43
|
+
when :blur then @mouse_down = false; @mouse_released = true
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def logical_point(event, scale)
|
|
51
|
+
[event[:x].to_f / scale, event[:y].to_f / scale]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def reset
|
|
55
|
+
@x = @y = 0
|
|
56
|
+
@mouse_down = @mouse_pressed = @mouse_released = false
|
|
57
|
+
@scroll_x = @scroll_y = 0
|
|
58
|
+
@chars = +""
|
|
59
|
+
@keys_pressed = []
|
|
60
|
+
@modifiers = []
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def ascii_for(key)
|
|
64
|
+
return unless key
|
|
65
|
+
|
|
66
|
+
name = key.to_s.downcase
|
|
67
|
+
character = case name
|
|
68
|
+
when "space" then " "
|
|
69
|
+
when /\A[a-z0-9]\z/ then name
|
|
70
|
+
end
|
|
71
|
+
return unless character
|
|
72
|
+
|
|
73
|
+
Array(@modifiers).map(&:to_sym).include?(:shift) ? character.upcase : character
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
class IdStack
|
|
78
|
+
def initialize
|
|
79
|
+
@stack = [0xcbf29ce484222325]
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def push(value)
|
|
83
|
+
@stack << hash(@stack.last, value.to_s)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def pop
|
|
87
|
+
raise Error, "ID stack is empty" if @stack.length == 1
|
|
88
|
+
@stack.pop
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def id(label)
|
|
92
|
+
hash(@stack.last, label.to_s)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
def hash(seed, value)
|
|
98
|
+
value.each_byte.reduce(seed) { |result, byte| ((result ^ byte) * 0x100000001b3) & 0xffffffffffffffff }
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
class DrawList
|
|
103
|
+
attr_reader :commands
|
|
104
|
+
|
|
105
|
+
def initialize
|
|
106
|
+
clear
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def clear
|
|
110
|
+
@commands = []
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def fill_rect(x, y, width, height, color) = @commands << [:fill_rect, x, y, width, height, color]
|
|
114
|
+
def rect(x, y, width, height, color) = @commands << [:rect, x, y, width, height, color]
|
|
115
|
+
def line(x1, y1, x2, y2, color) = @commands << [:line, x1, y1, x2, y2, color]
|
|
116
|
+
def text(value, x, y, color) = @commands << [:text, value, x, y, color]
|
|
117
|
+
def push_clip(x, y, width, height) = @commands << [:push_clip, x, y, width, height]
|
|
118
|
+
def pop_clip = @commands << [:pop_clip]
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
class Context
|
|
122
|
+
attr_reader :input, :draw_list
|
|
123
|
+
|
|
124
|
+
def initialize(font: nil, scale: 1.0, debug: false, theme: {})
|
|
125
|
+
@font = font || Glyphic.default
|
|
126
|
+
@scale = Float(scale)
|
|
127
|
+
raise ArgumentError, "scale must be positive" unless @scale.positive?
|
|
128
|
+
@debug = debug
|
|
129
|
+
@theme = DEFAULT_THEME.transform_values(&:dup)
|
|
130
|
+
set_theme(theme)
|
|
131
|
+
@input = Input.new
|
|
132
|
+
@ids = IdStack.new
|
|
133
|
+
@draw_list = DrawList.new
|
|
134
|
+
@windows = {}
|
|
135
|
+
@cursor = [0, 0]
|
|
136
|
+
@hot = @active = nil
|
|
137
|
+
@wants_mouse = @wants_keyboard = false
|
|
138
|
+
@text_states = {}
|
|
139
|
+
@drag_states = {}
|
|
140
|
+
@headers = {}
|
|
141
|
+
@focus = nil
|
|
142
|
+
@window_drag = nil
|
|
143
|
+
@last_y = nil
|
|
144
|
+
@time = 0.0
|
|
145
|
+
@hover_id = nil
|
|
146
|
+
@hover_since = nil
|
|
147
|
+
@used_ids = {}
|
|
148
|
+
@window_order = []
|
|
149
|
+
@window_ranges = []
|
|
150
|
+
@window_interaction_allowed = true
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def theme(values = nil, **overrides)
|
|
154
|
+
changes = (values || {}).merge(overrides)
|
|
155
|
+
return @theme.dup if changes.empty?
|
|
156
|
+
|
|
157
|
+
set_theme(changes)
|
|
158
|
+
self
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def frame(events: [], delta_time: 1.0 / 60.0)
|
|
162
|
+
delta_time = Float(delta_time)
|
|
163
|
+
raise ArgumentError, "delta_time must not be negative" if delta_time.negative?
|
|
164
|
+
@time += delta_time
|
|
165
|
+
@input.update(events, scale: @scale)
|
|
166
|
+
@active = nil if events.any? { |event| event[:type].to_sym == :blur }
|
|
167
|
+
@focus = nil if @input.mouse_pressed
|
|
168
|
+
@draw_list.clear
|
|
169
|
+
@cursor = [0, 0]
|
|
170
|
+
@hot = nil
|
|
171
|
+
@wants_mouse = @wants_keyboard = false
|
|
172
|
+
@last_y = nil
|
|
173
|
+
@used_ids.clear
|
|
174
|
+
@window_ranges.clear
|
|
175
|
+
yield self
|
|
176
|
+
reorder_windows
|
|
177
|
+
@active = nil if @input.mouse_released
|
|
178
|
+
@window_drag = nil if @input.mouse_released
|
|
179
|
+
@hover_id = @hover_since = nil unless @hot == @hover_id
|
|
180
|
+
self
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def window(title, x: 10, y: 10, width: 240, height: 200)
|
|
184
|
+
state = (@windows[title] ||= { x: x, y: y, width: width, height: height, cursor: 0, scroll_y: 0, content_height: 0 })
|
|
185
|
+
@window_order << state unless @window_order.include?(state)
|
|
186
|
+
command_start = @draw_list.commands.length
|
|
187
|
+
title_hit = point_in_rect?(@input.x, @input.y, [state[:x], state[:y], state[:width], 22])
|
|
188
|
+
collapse_hit = point_in_rect?(@input.x, @input.y, [state[:x] + state[:width] - 18, state[:y], 18, 22])
|
|
189
|
+
@wants_mouse = true if title_hit
|
|
190
|
+
if @window_drag == state && (@input.mouse_down || @input.mouse_released)
|
|
191
|
+
state[:x] = @input.x - state[:drag_offset_x]
|
|
192
|
+
state[:y] = @input.y - state[:drag_offset_y]
|
|
193
|
+
end
|
|
194
|
+
top_at_point = @window_order.reverse.find do |candidate|
|
|
195
|
+
point_in_rect?(@input.x, @input.y, [candidate[:x], candidate[:y], candidate[:width], candidate[:height]])
|
|
196
|
+
end
|
|
197
|
+
if @input.mouse_pressed && top_at_point.equal?(state)
|
|
198
|
+
@window_order.delete(state)
|
|
199
|
+
@window_order << state
|
|
200
|
+
if collapse_hit
|
|
201
|
+
state[:collapsed] = !state[:collapsed]
|
|
202
|
+
elsif title_hit && @window_drag.nil?
|
|
203
|
+
@window_drag = state
|
|
204
|
+
state[:drag_offset_x] = @input.x - state[:x]
|
|
205
|
+
state[:drag_offset_y] = @input.y - state[:y]
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
previous_window_interaction = @window_interaction_allowed
|
|
209
|
+
@window_interaction_allowed = top_at_point.nil? || top_at_point.equal?(state)
|
|
210
|
+
body_height = state[:collapsed] ? 0 : [state[:height] - 22, 0].max
|
|
211
|
+
body = [state[:x], state[:y] + 22, state[:width], body_height]
|
|
212
|
+
if @input.scroll_y != 0 && point_in_rect?(@input.x, @input.y, body)
|
|
213
|
+
max_scroll = [state[:content_height] - body_height, 0].max
|
|
214
|
+
state[:scroll_y] = [[state[:scroll_y] - @input.scroll_y * 20, 0].max, max_scroll].min
|
|
215
|
+
end
|
|
216
|
+
@draw_list.fill_rect(state[:x], state[:y], state[:width], state[:height], color(:window))
|
|
217
|
+
@draw_list.fill_rect(state[:x], state[:y], state[:width], 22, color(:title))
|
|
218
|
+
text(title, state[:x] + 8, state[:y] + 5)
|
|
219
|
+
text(state[:collapsed] ? "+" : "-", state[:x] + state[:width] - 14, state[:y] + 5)
|
|
220
|
+
previous = @cursor
|
|
221
|
+
previous_last_y = @last_y
|
|
222
|
+
previous_window = @window
|
|
223
|
+
previous_interaction_clip = @interaction_clip
|
|
224
|
+
@cursor = [state[:x] + 8, state[:y] + 30]
|
|
225
|
+
@cursor[1] -= state[:scroll_y]
|
|
226
|
+
@last_y = nil
|
|
227
|
+
@window = state
|
|
228
|
+
@interaction_clip = body
|
|
229
|
+
@ids.push(title)
|
|
230
|
+
@draw_list.push_clip(*body)
|
|
231
|
+
pushed_clip = true
|
|
232
|
+
yield self unless state[:collapsed]
|
|
233
|
+
ensure
|
|
234
|
+
@draw_list.pop_clip if pushed_clip
|
|
235
|
+
state[:content_height] = [@cursor[1] + state[:scroll_y] - body[1], 0].max if state && @cursor && body
|
|
236
|
+
state[:scroll_y] = [[state[:scroll_y], 0].max, [state[:content_height] - body[3], 0].max].min if state && body
|
|
237
|
+
@ids.pop if pushed_clip
|
|
238
|
+
if command_start
|
|
239
|
+
@window_ranges << [state, @draw_list.commands.slice!(command_start, @draw_list.commands.length - command_start)]
|
|
240
|
+
end
|
|
241
|
+
@window_interaction_allowed = previous_window_interaction if defined?(previous_window_interaction)
|
|
242
|
+
@cursor = previous if previous
|
|
243
|
+
@last_y = previous_last_y if defined?(previous_last_y)
|
|
244
|
+
@window = previous_window if defined?(previous_window)
|
|
245
|
+
@interaction_clip = previous_interaction_clip if defined?(previous_interaction_clip)
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def label(value)
|
|
249
|
+
text(value, @cursor[0], @cursor[1])
|
|
250
|
+
advance
|
|
251
|
+
nil
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def push_id(value) = @ids.push(value)
|
|
255
|
+
def pop_id = @ids.pop
|
|
256
|
+
|
|
257
|
+
def button(label, width: 100, height: 22)
|
|
258
|
+
id = @ids.id(label)
|
|
259
|
+
claim_id(id)
|
|
260
|
+
x, y = @cursor
|
|
261
|
+
hovered = hit?(x, y, width, height)
|
|
262
|
+
mark_hover(id) if hovered
|
|
263
|
+
@active = id if hovered && @input.mouse_pressed
|
|
264
|
+
clicked = @active == id && @input.mouse_released && hovered
|
|
265
|
+
@draw_list.fill_rect(x, y, width, height, hovered ? color(:button_hover) : color(:button))
|
|
266
|
+
text(label.split("##", 2).first, x + 6, y + 5)
|
|
267
|
+
advance(height)
|
|
268
|
+
clicked
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def checkbox(label, value)
|
|
272
|
+
changed = button("[#{value ? "x" : " "}] #{label}##checkbox", width: 150)
|
|
273
|
+
value = !value if changed
|
|
274
|
+
yield(value) if changed && block_given?
|
|
275
|
+
value
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def collapsing_header(label, open: true)
|
|
279
|
+
id = @ids.id("#{label}##header")
|
|
280
|
+
current = @headers.fetch(id, open)
|
|
281
|
+
if button("#{current ? "v" : ">"} #{label}##header", width: 200)
|
|
282
|
+
current = !current
|
|
283
|
+
@headers[id] = current
|
|
284
|
+
end
|
|
285
|
+
yield self if current && block_given?
|
|
286
|
+
current
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def radio(label, selected, value)
|
|
290
|
+
changed = button("#{selected == value ? "(*)" : "( )"} #{label}##radio-#{value}", width: 150)
|
|
291
|
+
selected = value if changed
|
|
292
|
+
yield(selected) if changed && block_given?
|
|
293
|
+
selected
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def slider(label, value, range, width: 160)
|
|
297
|
+
minimum, maximum = range.begin.to_f, range.end.to_f
|
|
298
|
+
raise ArgumentError, "slider range must increase" unless maximum > minimum
|
|
299
|
+
original = value
|
|
300
|
+
integer = value.is_a?(Integer)
|
|
301
|
+
id = @ids.id(label)
|
|
302
|
+
claim_id(id)
|
|
303
|
+
x, y = @cursor
|
|
304
|
+
hovered = hit?(x, y, width, 18)
|
|
305
|
+
mark_hover(id) if hovered
|
|
306
|
+
numeric_label = "#{label}##number"
|
|
307
|
+
direct_input = @focus == @ids.id(numeric_label) || hovered && @input.mouse_pressed && Array(@input.modifiers).map(&:to_sym).any? { |modifier| %i[control command].include?(modifier) }
|
|
308
|
+
if direct_input
|
|
309
|
+
return input_number(numeric_label, value, range: range, width: width) { |changed| yield(changed) if block_given? }
|
|
310
|
+
end
|
|
311
|
+
if hovered && @input.mouse_pressed
|
|
312
|
+
@active = id
|
|
313
|
+
end
|
|
314
|
+
if @active == id && @input.mouse_down
|
|
315
|
+
value = minimum + [[@input.x - x, 0].max, width].min.to_f / width * (maximum - minimum)
|
|
316
|
+
end
|
|
317
|
+
value = [[value.to_f, minimum].max, maximum].min
|
|
318
|
+
value = value.round if integer
|
|
319
|
+
@draw_list.fill_rect(x, y + 7, width, 4, color(:slider))
|
|
320
|
+
@draw_list.fill_rect(x, y + 7, ((value - minimum) / (maximum - minimum) * width).round, 4, color(:accent))
|
|
321
|
+
text("#{label}: #{value.round(3)}", x, y - 12)
|
|
322
|
+
advance(24)
|
|
323
|
+
yield(value) if block_given? && value != original
|
|
324
|
+
value
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def input_number(label, value, range:, step: nil, width: 180)
|
|
328
|
+
original = value
|
|
329
|
+
raise ArgumentError, "numeric input range must increase" unless range.end > range.begin
|
|
330
|
+
raise ArgumentError, "numeric input step must be positive" if step && step.to_f <= 0
|
|
331
|
+
raw = input_text(label, value, width: width)
|
|
332
|
+
parsed = Float(raw)
|
|
333
|
+
raise ArgumentError unless parsed.finite?
|
|
334
|
+
parsed = (parsed / step).round * step if step
|
|
335
|
+
parsed = [[parsed, range.begin.to_f].max, range.end.to_f].min
|
|
336
|
+
parsed = parsed.round if value.is_a?(Integer)
|
|
337
|
+
yield(parsed) if block_given? && parsed != original
|
|
338
|
+
parsed
|
|
339
|
+
rescue ArgumentError
|
|
340
|
+
original
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def drag(label, value, speed: 1.0, range: nil, width: 160)
|
|
344
|
+
id = @ids.id(label)
|
|
345
|
+
claim_id(id)
|
|
346
|
+
x, y = @cursor
|
|
347
|
+
state = (@drag_states[id] ||= {})
|
|
348
|
+
hovered = hit?(x, y, width, 18)
|
|
349
|
+
mark_hover(id) if hovered
|
|
350
|
+
if hovered && @input.mouse_pressed
|
|
351
|
+
@active = id
|
|
352
|
+
state[:start_x] = @input.x
|
|
353
|
+
state[:start_value] = value.to_f
|
|
354
|
+
end
|
|
355
|
+
if @active == id && @input.mouse_down
|
|
356
|
+
value = state[:start_value] + (@input.x - state[:start_x]) * speed.to_f
|
|
357
|
+
end
|
|
358
|
+
if range
|
|
359
|
+
value = [[value.to_f, range.begin.to_f].max, range.end.to_f].min
|
|
360
|
+
end
|
|
361
|
+
text("#{label}: #{value.round(3)}", x, y)
|
|
362
|
+
advance(24)
|
|
363
|
+
yield(value) if block_given? && value != state.fetch(:last_value, value)
|
|
364
|
+
state[:last_value] = value
|
|
365
|
+
value
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
def color_edit(label, value, alpha: value.length == 4, width: 160)
|
|
369
|
+
expected = alpha ? 4 : 3
|
|
370
|
+
raise ArgumentError, "color must have #{expected} channels" unless value.length == expected
|
|
371
|
+
channels = value.dup
|
|
372
|
+
names = alpha ? %w[R G B A] : %w[R G B]
|
|
373
|
+
names.each_with_index do |name, index|
|
|
374
|
+
channels[index] = slider("#{label} #{name}##{label}-#{name}", channels[index], 0..255, width: width)
|
|
375
|
+
end
|
|
376
|
+
channels
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
def input_text(label, value, width: 180)
|
|
380
|
+
id = @ids.id(label)
|
|
381
|
+
claim_id(id)
|
|
382
|
+
x, y = @cursor
|
|
383
|
+
state = (@text_states[id] ||= { value: value.to_s.dup, cursor: value.to_s.length })
|
|
384
|
+
state[:value] = value.to_s.dup unless @focus == id || state[:value] == value.to_s
|
|
385
|
+
state[:cursor] = [[state[:cursor], state[:value].length].min, 0].max
|
|
386
|
+
hovered = hit?(x, y, width, 22)
|
|
387
|
+
mark_hover(id) if hovered
|
|
388
|
+
if hovered && @input.mouse_pressed
|
|
389
|
+
@focus = id
|
|
390
|
+
state[:cursor] = state[:value].length
|
|
391
|
+
end
|
|
392
|
+
if @focus == id
|
|
393
|
+
@wants_keyboard = true
|
|
394
|
+
@input.keys_pressed.each do |key|
|
|
395
|
+
case key
|
|
396
|
+
when :left then state[:cursor] = [state[:cursor] - 1, 0].max
|
|
397
|
+
when :right then state[:cursor] = [state[:cursor] + 1, state[:value].length].min
|
|
398
|
+
when :home then state[:cursor] = 0
|
|
399
|
+
when :end then state[:cursor] = state[:value].length
|
|
400
|
+
when :backspace
|
|
401
|
+
if state[:cursor] > 0
|
|
402
|
+
state[:value].slice!(state[:cursor] - 1)
|
|
403
|
+
state[:cursor] -= 1
|
|
404
|
+
end
|
|
405
|
+
when :delete then state[:value].slice!(state[:cursor]) if state[:cursor] < state[:value].length
|
|
406
|
+
end
|
|
407
|
+
end
|
|
408
|
+
@input.chars.each_char do |character|
|
|
409
|
+
next if character.ord < 32
|
|
410
|
+
state[:value].insert(state[:cursor], character)
|
|
411
|
+
state[:cursor] += character.length
|
|
412
|
+
end
|
|
413
|
+
end
|
|
414
|
+
@draw_list.fill_rect(x, y, width, 22, color(:input))
|
|
415
|
+
text(state[:value], x + 4, y + 5)
|
|
416
|
+
@draw_list.line(x + 4 + state[:cursor] * 6, y + 4, x + 4 + state[:cursor] * 6, y + 19, color(:text)) if @focus == id
|
|
417
|
+
advance(26)
|
|
418
|
+
yield(state[:value]) if block_given? && state[:value] != value.to_s
|
|
419
|
+
state[:value]
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
def progress_bar(value, width: 160)
|
|
423
|
+
value = [[value.to_f, 0].max, 1].min
|
|
424
|
+
x, y = @cursor
|
|
425
|
+
@draw_list.fill_rect(x, y, width, 8, color(:progress_background))
|
|
426
|
+
@draw_list.fill_rect(x, y, (width * value).round, 8, color(:progress))
|
|
427
|
+
advance(16)
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
def separator
|
|
431
|
+
x, y = @cursor
|
|
432
|
+
@draw_list.line(x, y, x + 200, y, color(:separator))
|
|
433
|
+
advance(8)
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
def tooltip(value, delay: 0.5)
|
|
437
|
+
delay = Float(delay)
|
|
438
|
+
raise ArgumentError, "tooltip delay must not be negative" if delay.negative?
|
|
439
|
+
return false unless @hot && @hover_id == @hot && @time - @hover_since >= delay
|
|
440
|
+
|
|
441
|
+
x = @input.x + 8
|
|
442
|
+
y = @input.y + 8
|
|
443
|
+
width = value.to_s.length * 6 + 12
|
|
444
|
+
@draw_list.fill_rect(x, y, width, 20, color(:tooltip))
|
|
445
|
+
@draw_list.text(value, x + 6, y + 4, color(:text))
|
|
446
|
+
true
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
def same_line(x = nil)
|
|
450
|
+
@cursor[1] = @last_y if @last_y
|
|
451
|
+
@cursor[0] = x || @cursor[0] + 110
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
def spacing(amount = 8)
|
|
455
|
+
advance(amount)
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
def indent(amount = 20)
|
|
459
|
+
@cursor[0] += amount
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
def unindent(amount = 20)
|
|
463
|
+
@cursor[0] -= amount
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
def plot_lines(_label, values, height: 40, width: 160, minimum: nil, maximum: nil)
|
|
467
|
+
return advance(height) if values.empty?
|
|
468
|
+
x, y = @cursor
|
|
469
|
+
minimum ||= values.min
|
|
470
|
+
maximum ||= values.max
|
|
471
|
+
raise ArgumentError, "plot range must not descend" if maximum < minimum
|
|
472
|
+
span = [maximum - minimum, 1].max
|
|
473
|
+
values.each_cons(2).with_index do |(left, right), index|
|
|
474
|
+
x1 = x + index * width / [values.length - 1, 1].max
|
|
475
|
+
x2 = x + (index + 1) * width / [values.length - 1, 1].max
|
|
476
|
+
left_ratio = [[(left - minimum).to_f / span, 0].max, 1].min
|
|
477
|
+
right_ratio = [[(right - minimum).to_f / span, 0].max, 1].min
|
|
478
|
+
y1 = y + height - left_ratio * height
|
|
479
|
+
y2 = y + height - right_ratio * height
|
|
480
|
+
@draw_list.line(x1, y1, x2, y2, color(:plot))
|
|
481
|
+
end
|
|
482
|
+
advance(height)
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
def render(image)
|
|
486
|
+
if @scale != 1.0
|
|
487
|
+
logical = Tessel::Image.new((image.width / @scale).ceil, (image.height / @scale).ceil)
|
|
488
|
+
render_commands(logical)
|
|
489
|
+
image.blit(logical.scale_nearest(image.width, image.height), 0, 0, blend: :alpha)
|
|
490
|
+
return image
|
|
491
|
+
end
|
|
492
|
+
render_commands(image)
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
def render_commands(image)
|
|
496
|
+
clips = []
|
|
497
|
+
clip = nil
|
|
498
|
+
@draw_list.commands.each do |command|
|
|
499
|
+
case command[0]
|
|
500
|
+
when :fill_rect then fill(image, *command[1, 4], command[5], clip)
|
|
501
|
+
when :rect
|
|
502
|
+
x, y, width, height, color = command[1..]
|
|
503
|
+
fill(image, x, y, width, 1, color, clip)
|
|
504
|
+
fill(image, x, y + height - 1, width, 1, color, clip)
|
|
505
|
+
fill(image, x, y, 1, height, color, clip)
|
|
506
|
+
fill(image, x + width - 1, y, 1, height, color, clip)
|
|
507
|
+
when :line
|
|
508
|
+
x1, y1, x2, y2, color = command[1..]
|
|
509
|
+
x1, y1, x2, y2 = [x1, y1, x2, y2].map(&:round)
|
|
510
|
+
dx = (x2 - x1).abs
|
|
511
|
+
dy = -(y2 - y1).abs
|
|
512
|
+
sx = x1 < x2 ? 1 : -1
|
|
513
|
+
sy = y1 < y2 ? 1 : -1
|
|
514
|
+
error = dx + dy
|
|
515
|
+
loop do
|
|
516
|
+
image[x1, y1] = color if point_in_rect?(x1, y1, clip)
|
|
517
|
+
break if x1 == x2 && y1 == y2
|
|
518
|
+
twice = error * 2
|
|
519
|
+
if twice >= dy then error += dy; x1 += sx end
|
|
520
|
+
if twice <= dx then error += dx; y1 += sy end
|
|
521
|
+
end
|
|
522
|
+
when :text
|
|
523
|
+
draw_text(image, command[1], command[2], command[3], command[4], clip)
|
|
524
|
+
when :push_clip
|
|
525
|
+
clip = intersect_clip(clip, command[1, 4])
|
|
526
|
+
clips << clip
|
|
527
|
+
when :pop_clip
|
|
528
|
+
clip = clips.pop
|
|
529
|
+
end
|
|
530
|
+
end
|
|
531
|
+
image
|
|
532
|
+
end
|
|
533
|
+
|
|
534
|
+
def wants_mouse? = @wants_mouse || !@active.nil? || !@hot.nil?
|
|
535
|
+
def wants_keyboard? = @wants_keyboard
|
|
536
|
+
|
|
537
|
+
private
|
|
538
|
+
|
|
539
|
+
def text(value, x, y)
|
|
540
|
+
@draw_list.text(value.to_s, x, y, color(:text))
|
|
541
|
+
end
|
|
542
|
+
|
|
543
|
+
def fill(image, x, y, width, height, color, clip)
|
|
544
|
+
rectangle = clipped([x, y, width, height], clip)
|
|
545
|
+
return unless rectangle
|
|
546
|
+
|
|
547
|
+
image.fill_rect(*rectangle, color, blend: color.is_a?(String) ? :copy : :alpha)
|
|
548
|
+
end
|
|
549
|
+
|
|
550
|
+
def draw_text(image, value, x, y, color, clip)
|
|
551
|
+
return unless @font
|
|
552
|
+
return @font.draw(image, x, y, value, color: color) unless clip
|
|
553
|
+
|
|
554
|
+
layer = Tessel::Image.new(image.width, image.height)
|
|
555
|
+
@font.draw(layer, x, y, value, color: color)
|
|
556
|
+
rectangle = clipped([clip[0], clip[1], clip[2], clip[3]], [0, 0, image.width, image.height])
|
|
557
|
+
image.blit(layer, rectangle[0], rectangle[1], sx: rectangle[0], sy: rectangle[1], w: rectangle[2], h: rectangle[3], blend: :alpha) if rectangle
|
|
558
|
+
end
|
|
559
|
+
|
|
560
|
+
def advance(amount = 24)
|
|
561
|
+
@last_y = @cursor[1]
|
|
562
|
+
@cursor[1] += amount
|
|
563
|
+
end
|
|
564
|
+
|
|
565
|
+
def hit?(x, y, width, height)
|
|
566
|
+
@window_interaction_allowed && @input.x >= x && @input.x < x + width && @input.y >= y && @input.y < y + height && point_in_rect?(@input.x, @input.y, @interaction_clip)
|
|
567
|
+
end
|
|
568
|
+
|
|
569
|
+
def reorder_windows
|
|
570
|
+
ranges = @window_ranges.to_h { |state, commands| [state.object_id, commands] }
|
|
571
|
+
@window_order.each { |state| @draw_list.commands.concat(ranges[state.object_id] || []) }
|
|
572
|
+
@window_ranges.clear
|
|
573
|
+
end
|
|
574
|
+
|
|
575
|
+
def point_in_rect?(x, y, rectangle)
|
|
576
|
+
return true unless rectangle
|
|
577
|
+
|
|
578
|
+
x >= rectangle[0] && x < rectangle[0] + rectangle[2] && y >= rectangle[1] && y < rectangle[1] + rectangle[3]
|
|
579
|
+
end
|
|
580
|
+
|
|
581
|
+
def clipped(rectangle, clip)
|
|
582
|
+
return rectangle unless clip
|
|
583
|
+
|
|
584
|
+
x, y, width, height = rectangle
|
|
585
|
+
x0 = [x.ceil, clip[0]].max
|
|
586
|
+
y0 = [y.ceil, clip[1]].max
|
|
587
|
+
x1 = [(x + width).floor, clip[0] + clip[2]].min
|
|
588
|
+
y1 = [(y + height).floor, clip[1] + clip[3]].min
|
|
589
|
+
x0 < x1 && y0 < y1 ? [x0, y0, x1 - x0, y1 - y0] : nil
|
|
590
|
+
end
|
|
591
|
+
|
|
592
|
+
def intersect_clip(left, right)
|
|
593
|
+
return right unless left
|
|
594
|
+
|
|
595
|
+
clipped(right, left) || [0, 0, 0, 0]
|
|
596
|
+
end
|
|
597
|
+
|
|
598
|
+
def mark_hover(id)
|
|
599
|
+
if @hover_id != id
|
|
600
|
+
@hover_id = id
|
|
601
|
+
@hover_since = @time
|
|
602
|
+
end
|
|
603
|
+
@hot = id
|
|
604
|
+
end
|
|
605
|
+
|
|
606
|
+
def claim_id(id)
|
|
607
|
+
return unless @debug
|
|
608
|
+
raise Error, "duplicate widget id: #{id}" if @used_ids.key?(id)
|
|
609
|
+
|
|
610
|
+
@used_ids[id] = true
|
|
611
|
+
end
|
|
612
|
+
|
|
613
|
+
def set_theme(values)
|
|
614
|
+
raise TypeError, "theme must be a Hash" unless values.respond_to?(:each_pair)
|
|
615
|
+
|
|
616
|
+
values.each_pair do |key, value|
|
|
617
|
+
key = key.to_sym
|
|
618
|
+
raise ArgumentError, "unknown theme color: #{key}" unless DEFAULT_THEME.key?(key)
|
|
619
|
+
|
|
620
|
+
@theme[key] = Tessel::Color.pack(value).bytes.freeze
|
|
621
|
+
end
|
|
622
|
+
end
|
|
623
|
+
|
|
624
|
+
def color(key)
|
|
625
|
+
@theme.fetch(key)
|
|
626
|
+
end
|
|
627
|
+
end
|
|
628
|
+
end
|
data/sig/twiddle.rbs
ADDED
metadata
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: twiddle
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- ydah
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: tessel
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: 0.1.0
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: 0.1.0
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: glyphic
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: 0.1.0
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: 0.1.0
|
|
40
|
+
description: Small headless friendly widgets and a Tessel image renderer.
|
|
41
|
+
email:
|
|
42
|
+
- t.yudai92@gmail.com
|
|
43
|
+
executables: []
|
|
44
|
+
extensions: []
|
|
45
|
+
extra_rdoc_files: []
|
|
46
|
+
files:
|
|
47
|
+
- CHANGELOG.md
|
|
48
|
+
- LICENSE.txt
|
|
49
|
+
- README.md
|
|
50
|
+
- Rakefile
|
|
51
|
+
- docs/input-matrix.md
|
|
52
|
+
- lib/twiddle.rb
|
|
53
|
+
- lib/twiddle/context.rb
|
|
54
|
+
- lib/twiddle/id_stack.rb
|
|
55
|
+
- lib/twiddle/input.rb
|
|
56
|
+
- lib/twiddle/rbgl.rb
|
|
57
|
+
- lib/twiddle/version.rb
|
|
58
|
+
- sig/twiddle.rbs
|
|
59
|
+
homepage: https://github.com/rbgfx/twiddle
|
|
60
|
+
licenses:
|
|
61
|
+
- MIT
|
|
62
|
+
metadata:
|
|
63
|
+
homepage_uri: https://github.com/rbgfx/twiddle
|
|
64
|
+
source_code_uri: https://github.com/rbgfx/twiddle/tree/main
|
|
65
|
+
rdoc_options: []
|
|
66
|
+
require_paths:
|
|
67
|
+
- lib
|
|
68
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
69
|
+
requirements:
|
|
70
|
+
- - ">="
|
|
71
|
+
- !ruby/object:Gem::Version
|
|
72
|
+
version: 3.1.0
|
|
73
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
74
|
+
requirements:
|
|
75
|
+
- - ">="
|
|
76
|
+
- !ruby/object:Gem::Version
|
|
77
|
+
version: '0'
|
|
78
|
+
requirements: []
|
|
79
|
+
rubygems_version: 4.0.16
|
|
80
|
+
specification_version: 4
|
|
81
|
+
summary: Immediate mode UI for Ruby graphics
|
|
82
|
+
test_files: []
|