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 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
@@ -0,0 +1,7 @@
1
+ # Changelog
2
+
3
+ ## [Unreleased]
4
+
5
+ ## [0.1.0] - 2026-09-23
6
+
7
+ - Initial release.
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
+ [![Gem version](https://badge.fury.io/rb/twiddle.svg)](https://rubygems.org/gems/twiddle)
4
+ [![Downloads](https://img.shields.io/gem/dt/twiddle?label=downloads)](https://rubygems.org/gems/twiddle)
5
+ [![CI](https://github.com/rbgfx/twiddle/actions/workflows/ci.yml/badge.svg)](https://github.com/rbgfx/twiddle/actions/workflows/ci.yml)
6
+ [![Ruby](https://img.shields.io/badge/ruby-%3E%3D3.1-CC342D?logo=ruby&logoColor=white)](https://www.ruby-lang.org/)
7
+ [![License](https://img.shields.io/badge/license-MIT-750014.svg)](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,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec) { |task| task.ruby_opts = ["-I../tessel/lib", "-I../glyphic/lib"] }
7
+
8
+ task default: :spec
9
+ task verify: :spec
@@ -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.
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../twiddle"
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../twiddle"
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../twiddle"
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Twiddle
4
+ VERSION = "0.1.0"
5
+ 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
@@ -0,0 +1,4 @@
1
+ module Twiddle
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
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: []