Class: Clogs::App

Inherits:
Drawable
  • Object
show all
Defined in:
lib/clogs/app.rb

Overview

The display side of Shoes::App: one libui window, one canvas, and the plumbing that turns libui's callbacks into Shoes events.

Constant Summary collapse

BACKGROUND =

---- painting -----------------------------------------------------

[255, 255, 255, 255].freeze

Class Attribute Summary collapse

Instance Attribute Summary collapse

Attributes inherited from Drawable

#abs_x, #abs_y, #children, #height, #parent, #shoes_linkable_id, #styles, #width, #x, #y

Instance Method Summary collapse

Methods inherited from Drawable

#add_child, #clickable?, #contains?, #destroy_self, #draw, #each_peer, #focus_gained, #focus_lost, #focusable?, for_shoes_class, #hidden?, #margin, #measure, #notify, #on_click, #on_mouse_move, #on_release, #paint, #positioned?, #properties_changed, #remove_child, #requested_height, #requested_width, #set_parent, #style

Constructor Details

#initialize(properties) ⇒ App

Returns a new instance of App.



23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
# File 'lib/clogs/app.rb', line 23

def initialize(properties)
  super
  App.instance = self
  @timers = []
  @needs_layout = true
  @focused = nil
  @hovered = nil
  @mouse_state = [0, 0, 0]

  bind_shoes_event(event_name: "init") { init }
  bind_shoes_event(event_name: "run") { run }
  bind_shoes_event(event_name: "destroy") { destroy }
  Shoes::DisplayService.subscribe_to_event("builtin", nil) do |cmd, args|
    App.builtin_response = builtin(cmd, args)
  end
end

Class Attribute Details

.builtin_responseObject

Set synchronously while a builtin event is being handled, so that ask/confirm can return a value even on Lacci versions that have no response channel of their own.



18
19
20
# File 'lib/clogs/app.rb', line 18

def builtin_response
  @builtin_response
end

.instanceObject

Returns the value of attribute instance.



13
14
15
# File 'lib/clogs/app.rb', line 13

def instance
  @instance
end

Instance Attribute Details

#canvasObject (readonly)

Returns the value of attribute canvas.



21
22
23
# File 'lib/clogs/app.rb', line 21

def canvas
  @canvas
end

#document_rootObject

Returns the value of attribute document_root.



21
22
23
# File 'lib/clogs/app.rb', line 21

def document_root
  @document_root
end

#mouse_stateObject (readonly)

Returns the value of attribute mouse_state.



21
22
23
# File 'lib/clogs/app.rb', line 21

def mouse_state
  @mouse_state
end

#windowObject (readonly)

Returns the value of attribute window.



21
22
23
# File 'lib/clogs/app.rb', line 21

def window
  @window
end

Instance Method Details

#add_timer(interval_ms, repeat: true, &block) ⇒ Object

libui's timer callback returns nonzero to keep firing.

Timers may only be armed once the main loop is running: Shoes programs routinely call animate or every while the app is still being built, and handing libui a timer before then crashes it. Queue those and arm them when the loop starts.



337
338
339
340
341
342
343
344
345
346
347
348
# File 'lib/clogs/app.rb', line 337

def add_timer(interval_ms, repeat: true, &block)
  interval_ms = 1 if interval_ms.to_i < 1
  unless @running
    (@pending_timers ||= []) << [interval_ms, repeat, block]
    return
  end

  # Arming a timer from inside a libui callback re-enters the loop's own
  # bookkeeping and crashes; queue_main defers it to a safe point. Shoes
  # programs create timers from event handlers all the time.
  UI::L.queue_main { arm_timer(interval_ms, repeat, block) }
end

#appObject



40
41
42
# File 'lib/clogs/app.rb', line 40

def app
  self
end

#app_heightObject



52
53
54
# File 'lib/clogs/app.rb', line 52

def app_height
  (style(:height) || 420).to_i
end

#app_widthObject



48
49
50
# File 'lib/clogs/app.rb', line 48

def app_width
  (style(:width) || 480).to_i
end

#arm_timer(interval_ms, repeat, block) ⇒ Object

The libui binding builds the callback itself when given a block, which is the only way to get the int (*)(void *) signature right; a hand-rolled closure with the wrong arity corrupts the stack. Returning nonzero keeps the timer running.



360
361
362
363
364
365
366
367
368
369
370
371
372
373
# File 'lib/clogs/app.rb', line 360

def arm_timer(interval_ms, repeat, block)
  UI::L.timer(interval_ms) do
    if @destroyed
      0
    else
      begin
        block.call
      rescue StandardError => e
        report_error(e)
      end
      repeat ? 1 : 0
    end
  end
end

#arm_timersObject



350
351
352
353
354
# File 'lib/clogs/app.rb', line 350

def arm_timers
  pending = @pending_timers || []
  @pending_timers = nil
  pending.each { |interval, repeat, block| arm_timer(interval, repeat, block) }
end

#builtin(cmd, args) ⇒ Object

---- builtins -----------------------------------------------------



377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
# File 'lib/clogs/app.rb', line 377

def builtin(cmd, args)
  case cmd.to_s
  when "alert" then Dialogs.alert(@window, args[0])
  when "confirm" then Dialogs.confirm(@window, args[0])
  when "ask" then Dialogs.ask(@window, args[0])
  when "ask_open_file" then Dialogs.open_file(@window)
  when "ask_save_file" then Dialogs.save_file(@window)
  when "ask_open_folder" then Dialogs.open_folder(@window)
  when "ask_color" then nil
  when "font" then nil
  end
rescue StandardError => e
  report_error(e)
  nil
end

#capture_screenshot(path) ⇒ Object



128
129
130
131
132
# File 'lib/clogs/app.rb', line 128

def capture_screenshot(path)
  # ImageMagick's `import` is the only dependency-free way to do this on
  # X11; elsewhere the screenshot is simply skipped.
  system("import", "-window", "root", path, err: File::NULL)
end

#check_test_deadlineObject

A busy app -- an animation that cannot keep up on a slow machine, say -- can starve a one-shot timer indefinitely. The deadline is therefore also checked from the paint callback, so the run ends as long as anything is still happening at all.



113
114
115
116
117
118
# File 'lib/clogs/app.rb', line 113

def check_test_deadline
  return unless @exit_deadline
  return if Process.clock_gettime(Process::CLOCK_MONOTONIC) < @exit_deadline

  finish_test_run
end

#destroyObject



134
135
136
137
138
139
# File 'lib/clogs/app.rb', line 134

def destroy
  return if @destroyed

  @destroyed = true
  UI::L.quit
end

#finish_test_runObject



120
121
122
123
124
125
126
# File 'lib/clogs/app.rb', line 120

def finish_test_run
  return if @exit_deadline.nil?

  @exit_deadline = nil
  capture_screenshot(ENV["CLOGS_SCREENSHOT"]) if ENV["CLOGS_SCREENSHOT"]
  quit
end

#handle_press(event) ⇒ Object



222
223
224
225
226
227
228
229
230
231
232
# File 'lib/clogs/app.rb', line 222

def handle_press(event)
  # A click outside an open drop-down closes it.
  open_list_boxes.each { |lb| lb.close unless lb.contains?(event.x, event.y) || lb.overlay_contains?(event.x, event.y) }

  target = open_list_boxes.find { |lb| lb.overlay_contains?(event.x, event.y) } ||
    topmost_clickable(event.x, event.y)
  set_focus(target&.focusable? ? target : nil)
  target&.on_click(event.x, event.y, event.down)
  @pressed = target
  notify_subscribers("click", event.down, event.x.round, event.y.round)
end

#handle_release(event) ⇒ Object



234
235
236
237
238
# File 'lib/clogs/app.rb', line 234

def handle_release(event)
  @pressed&.on_release(event.x, event.y, event.up)
  @pressed = nil
  notify_subscribers("release", event.up, event.x.round, event.y.round)
end

#initObject



56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
# File 'lib/clogs/app.rb', line 56

def init
  UI::L.init unless @initialized
  @initialized = true

  @window = UI::L.new_window(title, app_width, app_height, 0)
  UI::L.window_set_margined(@window, 0)
  UI::L.window_on_closing(@window) do
    quit
    0
  end

  @canvas = Canvas.new
  @canvas.on_draw = method(:on_draw)
  @canvas.on_mouse = method(:on_mouse)
  @canvas.on_key = method(:on_key)
  @canvas.on_crossed = method(:on_crossed)

  # An area needs a stretchy box around it or libui gives it no size.
  box = UI::L.new_vertical_box
  UI::L.box_append(box, @canvas.area, 1)
  UI::L.window_set_child(@window, box)
end

#install_test_hooksObject

Hooks for automated testing: run the app for a fixed time, optionally capture the screen, then quit. Used by Clogs' own visual tests and handy for any Shoes app under CI.

CLOGS_EXIT_AFTER_MS=800 CLOGS_SCREENSHOT=out.png ruby app.rb


101
102
103
104
105
106
107
# File 'lib/clogs/app.rb', line 101

def install_test_hooks
  ms = ENV["CLOGS_EXIT_AFTER_MS"]&.to_i
  return if ms.nil? || ms <= 0

  @exit_deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + ms / 1000.0
  add_timer(ms, repeat: false) { finish_test_run }
end

#key_name(event) ⇒ Object

Shoes reports keys as single characters, names like "up", or "control_x" style combinations.



288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
# File 'lib/clogs/app.rb', line 288

def key_name(event)
  base = if event.ext
    event.ext.to_s
  elsif event.char
    case event.char
    when "\b", "\x7F" then "backspace"
    when "\r", "\n" then "\n"
    when "\t" then "tab"
    when " " then " "
    else event.char
    end
  end
  return nil unless base

  mods = []
  mods << "control" if event.ctrl?
  mods << "alt" if event.alt?
  mods << "shift" if event.shift? && base.length > 1
  mods.empty? ? base : (mods + [base]).join("_")
end

#needs_layout!Object



152
153
154
155
# File 'lib/clogs/app.rb', line 152

def needs_layout!
  @needs_layout = true
  redraw!
end

#notify_subscribers(api_name, *args) ⇒ Object



325
326
327
328
329
# File 'lib/clogs/app.rb', line 325

def notify_subscribers(api_name, *args)
  subscriptions.each do |sub|
    sub.notify(api_name, *args) if sub.api_name == api_name
  end
end

#on_crossed(left) ⇒ Object



309
310
311
312
313
314
315
# File 'lib/clogs/app.rb', line 309

def on_crossed(left)
  return unless left

  @hovered&.on_mouse_leave if @hovered.respond_to?(:on_mouse_leave)
  @hovered = nil
  redraw!
end

#on_draw(painter, _params) ⇒ Object



167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
# File 'lib/clogs/app.rb', line 167

def on_draw(painter, _params)
  check_test_deadline
  painter.fill_rect(0, 0, painter.width, painter.height, BACKGROUND)
  return unless @document_root

  if @needs_layout || @last_width != painter.width
    @document_root.measure(painter.width)
    @last_width = painter.width
    @needs_layout = false
  end
  @document_root.paint(painter, 0, 0)
  open_list_boxes.each { |lb| lb.draw_overlay(painter) }
rescue StandardError => e
  report_error(e)
end

#on_key(event) ⇒ Object



269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
# File 'lib/clogs/app.rb', line 269

def on_key(event)
  return false if @destroyed

  if @focused&.on_key(event)
    redraw!
    return true
  end
  name = key_name(event)
  return false unless name && !event.up

  notify_subscribers("keypress", name)
  true
rescue StandardError => e
  report_error(e)
  false
end

#on_mouse(event) ⇒ Object



205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
# File 'lib/clogs/app.rb', line 205

def on_mouse(event)
  @mouse_state = [event.button_down? ? 1 : 0, event.x.round, event.y.round]
  Shoes::DisplayService.mouse_state = @mouse_state if Shoes::DisplayService.respond_to?(:mouse_state=)

  update_hover(event)
  notify_subscribers("motion", event.x.round, event.y.round,
    event.modifiers.anybits?(UI::MOD_CTRL), event.modifiers.anybits?(UI::MOD_SHIFT))

  if event.down.positive?
    handle_press(event)
  elsif event.up.positive?
    handle_release(event)
  end
rescue StandardError => e
  report_error(e)
end

#open_list_boxesObject



183
184
185
186
187
188
189
# File 'lib/clogs/app.rb', line 183

def open_list_boxes
  return [] unless @document_root

  found = []
  @document_root.each_peer { |peer| found << peer if peer.is_a?(ListBox) && peer.open? }
  found
end

#peers_at(x, y) ⇒ Object

---- input --------------------------------------------------------



193
194
195
196
197
198
199
# File 'lib/clogs/app.rb', line 193

def peers_at(x, y)
  return [] unless @document_root

  hits = []
  @document_root.each_peer { |peer| hits << peer if peer.contains?(x, y) }
  hits
end

#quitObject



141
142
143
144
# File 'lib/clogs/app.rb', line 141

def quit
  notify("destroy")
  destroy
end

#redraw!Object



157
158
159
160
161
# File 'lib/clogs/app.rb', line 157

def redraw!
  return if @destroyed

  @canvas&.redraw
end

#report_error(error) ⇒ Object



393
394
395
396
# File 'lib/clogs/app.rb', line 393

def report_error(error)
  warn "Clogs error: #{error.class}: #{error.message}"
  warn error.backtrace.first(12).join("\n") if error.backtrace
end

#runObject



79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
# File 'lib/clogs/app.rb', line 79

def run
  UI::L.control_show(@window)
  @running = true
  arm_timers
  install_test_hooks
  UI::L.main
  # libui aborts on exit if a control is still alive, so tear the window
  # down explicitly. Destroying the window destroys its children.
  UI::L.control_destroy(@window) if @window
  @window = nil
  # Anything that runs after the window is gone -- at_exit handlers, an
  # app's own shutdown code -- must not touch the area again. Queueing a
  # redraw on a destroyed area segfaults.
  @canvas = nil
  UI::L.uninit if @initialized
end

#set_focus(peer) ⇒ Object



257
258
259
260
261
262
263
264
265
266
267
# File 'lib/clogs/app.rb', line 257

def set_focus(peer)
  return if peer == @focused

  @focused&.focused = false
  @focused&.focus_lost
  @focused = peer
  return unless peer

  peer.focused = true
  peer.focus_gained
end

#subscriptionsObject

---- subscriptions and timers -------------------------------------



319
320
321
322
323
# File 'lib/clogs/app.rb', line 319

def subscriptions
  found = []
  @document_root&.each_peer { |peer| found << peer if peer.is_a?(SubscriptionItem) }
  found
end

#titleObject



44
45
46
# File 'lib/clogs/app.rb', line 44

def title
  style(:title) || "Shoes"
end

#topmost_clickable(x, y) ⇒ Object



201
202
203
# File 'lib/clogs/app.rb', line 201

def topmost_clickable(x, y)
  peers_at(x, y).reverse.find(&:clickable?)
end

#update_hover(event) ⇒ Object



240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
# File 'lib/clogs/app.rb', line 240

def update_hover(event)
  target = peers_at(event.x, event.y).reverse.find { |p| p.clickable? || p.is_a?(Control) }
  return if target == @hovered

  if @hovered
    @hovered.on_mouse_leave if @hovered.respond_to?(:on_mouse_leave)
    @hovered.notify("leave")
  end
  @hovered = target
  if target
    target.on_mouse_enter if target.respond_to?(:on_mouse_enter)
    target.notify("hover")
  end
  notify_subscribers("hover") if target
  notify_subscribers("leave") unless target
end