qt 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.
Files changed (45) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +27 -0
  3. data/README.md +303 -0
  4. data/Rakefile +94 -0
  5. data/examples/development_ordered_demos/01_dsl_hello.rb +22 -0
  6. data/examples/development_ordered_demos/02_live_layout_console.rb +137 -0
  7. data/examples/development_ordered_demos/03_component_showcase.rb +235 -0
  8. data/examples/development_ordered_demos/04_paint_simple.rb +147 -0
  9. data/examples/development_ordered_demos/05_tetris_simple.rb +295 -0
  10. data/examples/development_ordered_demos/06_timetrap_clockify.rb +759 -0
  11. data/examples/development_ordered_demos/07_peek_like_recorder.rb +597 -0
  12. data/examples/qtproject/widgets/itemviews/spreadsheet/main.rb +252 -0
  13. data/examples/qtproject/widgets/widgetsgallery/main.rb +184 -0
  14. data/ext/qt_ruby_bridge/extconf.rb +75 -0
  15. data/ext/qt_ruby_bridge/qt_ruby_runtime.hpp +23 -0
  16. data/ext/qt_ruby_bridge/runtime_events.cpp +408 -0
  17. data/ext/qt_ruby_bridge/runtime_signals.cpp +212 -0
  18. data/lib/qt/application_lifecycle.rb +44 -0
  19. data/lib/qt/bridge.rb +95 -0
  20. data/lib/qt/children_tracking.rb +15 -0
  21. data/lib/qt/constants.rb +10 -0
  22. data/lib/qt/date_time_codec.rb +104 -0
  23. data/lib/qt/errors.rb +6 -0
  24. data/lib/qt/event_runtime.rb +139 -0
  25. data/lib/qt/event_runtime_dispatch.rb +35 -0
  26. data/lib/qt/event_runtime_qobject_methods.rb +41 -0
  27. data/lib/qt/generated_constants_runtime.rb +33 -0
  28. data/lib/qt/inspectable.rb +29 -0
  29. data/lib/qt/key_sequence_codec.rb +22 -0
  30. data/lib/qt/native.rb +93 -0
  31. data/lib/qt/shortcut_compat.rb +30 -0
  32. data/lib/qt/string_codec.rb +44 -0
  33. data/lib/qt/variant_codec.rb +78 -0
  34. data/lib/qt/version.rb +5 -0
  35. data/lib/qt.rb +47 -0
  36. data/scripts/generate_bridge/ast_introspection.rb +267 -0
  37. data/scripts/generate_bridge/auto_method_spec_resolver.rb +37 -0
  38. data/scripts/generate_bridge/auto_methods.rb +438 -0
  39. data/scripts/generate_bridge/core_utils.rb +114 -0
  40. data/scripts/generate_bridge/cpp_method_return_emitter.rb +93 -0
  41. data/scripts/generate_bridge/ffi_api.rb +46 -0
  42. data/scripts/generate_bridge/free_function_specs.rb +289 -0
  43. data/scripts/generate_bridge/spec_discovery.rb +313 -0
  44. data/scripts/generate_bridge.rb +1113 -0
  45. metadata +99 -0
@@ -0,0 +1,597 @@
1
+ # frozen_string_literal: true
2
+
3
+ $LOAD_PATH.unshift(File.expand_path('../../lib', __dir__))
4
+ require 'qt'
5
+ require 'ffi'
6
+ require 'fileutils'
7
+ require 'securerandom'
8
+ require 'tmpdir'
9
+ require 'timeout'
10
+
11
+ CTRL_MODIFIER = 0x04000000
12
+ ALT_MODIFIER = 0x08000000
13
+ KEY_R = 0x52
14
+ KEY_Q = 0x51
15
+ LEFT_BUTTON = 1
16
+
17
+ # Qt::WindowType / Qt::WidgetAttribute values used as plain integers.
18
+ WINDOW_FLAG_WINDOW = 0x00000001
19
+ WINDOW_FLAG_FRAMELESS = 0x00000800
20
+ WINDOW_FLAG_ALWAYS_ON_TOP = 0x00040000
21
+ WINDOW_FLAG_TRANSPARENT_FOR_INPUT = 0x00080000
22
+ WA_TRANSPARENT_FOR_MOUSE_EVENTS = 51
23
+ WA_NO_SYSTEM_BACKGROUND = 9
24
+ WA_TRANSLUCENT_BACKGROUND = 120
25
+
26
+ FRAME_IDLE_STYLE = 'background-color: rgba(16, 185, 129, 0.08); border: 0px;'
27
+ FRAME_RECORDING_STYLE = 'background-color: rgba(239, 68, 68, 0.08); border: 0px;'
28
+ BORDER_IDLE_STYLE = 'background-color: rgba(0, 0, 0, 0); border: 1px solid #10b981;'
29
+ BORDER_RECORDING_STYLE = 'background-color: rgba(0, 0, 0, 0); border: 1px solid #ef4444;'
30
+ HANDLE_IDLE_STYLE = 'background-color: rgba(15, 23, 42, 0.88); border: 1px solid #334155; color: #e2e8f0; font-size: 12px; font-weight: 700;'
31
+ HANDLE_RECORDING_STYLE = 'background-color: rgba(127, 29, 29, 0.92); border: 1px solid #ef4444; color: #fee2e2; font-size: 12px; font-weight: 700;'
32
+ REC_INDICATOR_STYLE = 'background-color: #ef4444; border: 2px solid #fee2e2; border-radius: 9px;'
33
+
34
+ HANDLE_W = 320
35
+ HANDLE_H = 34
36
+ HANDLE_PADDING = 12
37
+ MIN_FRAME_W = 160
38
+ MIN_FRAME_H = 120
39
+ RESIZE_EDGE = 8
40
+ RESIZE_CORNER = 14
41
+ RESIZE_HANDLE_STYLE = 'background-color: rgba(148, 163, 184, 0.20); border: 1px solid rgba(100, 116, 139, 0.55);'
42
+
43
+ module X11GlobalHotkey
44
+ extend self
45
+
46
+ KEYSYM_R = 0x72
47
+ KEY_PRESS = 2
48
+
49
+ CONTROL_MASK = 1 << 2
50
+ ALT_MASK = 1 << 3
51
+ LOCK_MASK = 1 << 1
52
+ MOD2_MASK = 1 << 4
53
+
54
+ GRAB_MODE_ASYNC = 1
55
+
56
+ class XKeyEvent < FFI::Struct
57
+ layout :type, :int,
58
+ :serial, :ulong,
59
+ :send_event, :int,
60
+ :display, :pointer,
61
+ :window, :ulong,
62
+ :root, :ulong,
63
+ :subwindow, :ulong,
64
+ :time, :ulong,
65
+ :x, :int,
66
+ :y, :int,
67
+ :x_root, :int,
68
+ :y_root, :int,
69
+ :state, :uint,
70
+ :keycode, :uint,
71
+ :same_screen, :int
72
+ end
73
+
74
+ class XEvent < FFI::Union
75
+ layout :type, :int,
76
+ :xkey, XKeyEvent,
77
+ :pad, [:long, 24]
78
+ end
79
+
80
+ module Lib
81
+ extend FFI::Library
82
+ ffi_lib ['X11', 'libX11.so.6']
83
+
84
+ attach_function :x_open_display, :XOpenDisplay, [:pointer], :pointer
85
+ attach_function :x_default_root_window, :XDefaultRootWindow, [:pointer], :ulong
86
+ attach_function :x_keysym_to_keycode, :XKeysymToKeycode, %i[pointer ulong], :uint
87
+ attach_function :x_grab_key, :XGrabKey, %i[pointer int uint ulong int int int], :int
88
+ attach_function :x_ungrab_key, :XUngrabKey, %i[pointer int uint ulong], :int
89
+ attach_function :x_pending, :XPending, [:pointer], :int
90
+ attach_function :x_next_event, :XNextEvent, %i[pointer pointer], :int
91
+ attach_function :x_sync, :XSync, %i[pointer int], :int
92
+ attach_function :x_close_display, :XCloseDisplay, [:pointer], :int
93
+ end
94
+
95
+ def start_listener
96
+ return nil if ENV['DISPLAY'].to_s.strip.empty?
97
+
98
+ display = Lib.x_open_display(nil)
99
+ return nil if display.null?
100
+
101
+ root = Lib.x_default_root_window(display)
102
+ keycode = Lib.x_keysym_to_keycode(display, KEYSYM_R)
103
+ return nil if keycode.zero?
104
+
105
+ base_mod = CONTROL_MASK | ALT_MASK
106
+ modifier_combinations = [
107
+ base_mod,
108
+ base_mod | LOCK_MASK,
109
+ base_mod | MOD2_MASK,
110
+ base_mod | LOCK_MASK | MOD2_MASK
111
+ ]
112
+
113
+ modifier_combinations.each do |mod|
114
+ Lib.x_grab_key(display, keycode, mod, root, 1, GRAB_MODE_ASYNC, GRAB_MODE_ASYNC)
115
+ end
116
+ Lib.x_sync(display, 0)
117
+
118
+ {
119
+ display: display,
120
+ root: root,
121
+ keycode: keycode,
122
+ mods: modifier_combinations,
123
+ event: XEvent.new,
124
+ last_trigger_at: 0.0
125
+ }
126
+ rescue LoadError, FFI::NotFoundError => e
127
+ warn "[hotkey-listener] unavailable: #{e.message}"
128
+ nil
129
+ end
130
+
131
+ def poll(listener)
132
+ return 0 if listener.nil?
133
+
134
+ triggered = 0
135
+ while Lib.x_pending(listener[:display]).positive?
136
+ Lib.x_next_event(listener[:display], listener[:event].pointer)
137
+ next unless listener[:event][:type] == KEY_PRESS
138
+
139
+ key_event = XKeyEvent.new(listener[:event].pointer)
140
+ state = key_event[:state]
141
+ next unless (state & (CONTROL_MASK | ALT_MASK)) == (CONTROL_MASK | ALT_MASK)
142
+ next unless key_event[:keycode] == listener[:keycode]
143
+
144
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
145
+ next if (now - listener[:last_trigger_at]) < 0.25
146
+
147
+ listener[:last_trigger_at] = now
148
+ triggered += 1
149
+ end
150
+
151
+ triggered
152
+ rescue StandardError => e
153
+ warn "[hotkey-listener] poll failed: #{e.class}: #{e.message}"
154
+ 0
155
+ end
156
+
157
+ def stop_listener(listener)
158
+ return if listener.nil?
159
+
160
+ listener[:mods].each do |mod|
161
+ Lib.x_ungrab_key(listener[:display], listener[:keycode], mod, listener[:root])
162
+ end
163
+ Lib.x_sync(listener[:display], 0)
164
+ Lib.x_close_display(listener[:display]) unless listener[:display].null?
165
+ rescue StandardError => e
166
+ warn "[hotkey-listener] stop failed: #{e.class}: #{e.message}"
167
+ end
168
+ end
169
+
170
+ def recordings_dir
171
+ File.join(Dir.home, 'Видео', 'Записи экрана')
172
+ end
173
+
174
+ def position_config_path
175
+ File.join(Dir.home, '.config', 'qpeek', 'position.conf')
176
+ end
177
+
178
+ def load_position(path)
179
+ return nil unless File.exist?(path)
180
+
181
+ values = {}
182
+ File.readlines(path, chomp: true).each do |line|
183
+ key, raw = line.split('=', 2)
184
+ next if key.nil? || raw.nil?
185
+
186
+ values[key.strip] = Integer(raw.strip, exception: false)
187
+ end
188
+
189
+ left = values['left']
190
+ top = values['top']
191
+ width = values['width']
192
+ height = values['height']
193
+ return nil unless left && top && width && height
194
+
195
+ { left: left, top: top, width: width, height: height }
196
+ end
197
+
198
+ def save_position(path, window)
199
+ FileUtils.mkdir_p(File.dirname(path))
200
+ File.write(
201
+ path,
202
+ "top=#{window.y}\nleft=#{window.x}\nwidth=#{window.width}\nheight=#{window.height}\n"
203
+ )
204
+ end
205
+
206
+ def ffmpeg_available?
207
+ system('command -v ffmpeg >/dev/null 2>&1')
208
+ end
209
+
210
+ def now_stamp
211
+ Time.now.strftime('%Y-%m-%d_%H-%M-%S')
212
+ end
213
+
214
+ def ensure_even(value)
215
+ value.even? ? value : value - 1
216
+ end
217
+
218
+ def selected_geometry(window)
219
+ x = window.x
220
+ y = window.y
221
+ w = ensure_even(window.width)
222
+ h = ensure_even(window.height)
223
+ [x, y, [w, 2].max, [h, 2].max]
224
+ end
225
+
226
+ def stop_recorder!(pid)
227
+ return unless pid
228
+
229
+ begin
230
+ Process.kill('INT', pid)
231
+ rescue Errno::ESRCH
232
+ return
233
+ end
234
+
235
+ begin
236
+ Timeout.timeout(5) { Process.wait(pid) }
237
+ rescue Timeout::Error
238
+ Process.kill('TERM', pid) rescue nil
239
+ Process.wait(pid) rescue nil
240
+ rescue Errno::ECHILD
241
+ nil
242
+ end
243
+ end
244
+
245
+ def choose_output_path(parent)
246
+ FileUtils.mkdir_p(recordings_dir)
247
+ suggested = File.join(recordings_dir, "screen-record-#{now_stamp}.mp4")
248
+
249
+ dialog = Qt::QFileDialog.new(parent)
250
+ selected = dialog.get_save_file_name(
251
+ parent,
252
+ 'Сохранить запись',
253
+ suggested,
254
+ 'MP4 video (*.mp4)',
255
+ nil,
256
+ 0
257
+ )
258
+
259
+ return nil if selected.nil? || selected.strip.empty?
260
+
261
+ selected.end_with?('.mp4') ? selected : "#{selected}.mp4"
262
+ end
263
+
264
+ def start_recorder(window)
265
+ x, y, w, h = selected_geometry(window)
266
+ display = ENV.fetch('DISPLAY', ':0')
267
+ temp_file = File.join(Dir.tmpdir, "qt-peek-like-#{now_stamp}-#{SecureRandom.hex(4)}.mp4")
268
+
269
+ ffmpeg_args = [
270
+ 'ffmpeg',
271
+ '-f', 'x11grab',
272
+ '-show_region', '0',
273
+ '-framerate', '25',
274
+ '-video_size', "#{w}x#{h}",
275
+ '-i', "#{display}+#{x},#{y}",
276
+ '-filter:v', 'scale=iw/1:-1,crop=iw-mod(iw\\,2):ih-mod(ih\\,2)',
277
+ '-codec:v', 'libx264',
278
+ '-preset:v', 'fast',
279
+ '-pix_fmt', 'yuv420p',
280
+ '-r', '25',
281
+ '-y', temp_file
282
+ ]
283
+
284
+ log_file = File.join(Dir.tmpdir, "qt-peek-like-ffmpeg-#{now_stamp}.log")
285
+ log_io = File.open(log_file, 'a')
286
+ pid = Process.spawn(*ffmpeg_args, out: log_io, err: log_io)
287
+ log_io.close
288
+
289
+ [pid, temp_file, log_file, [x, y, w, h]]
290
+ end
291
+
292
+ def floating_flags
293
+ WINDOW_FLAG_WINDOW | WINDOW_FLAG_FRAMELESS | WINDOW_FLAG_ALWAYS_ON_TOP
294
+ end
295
+
296
+ def apply_handle_state(label, recording)
297
+ if recording
298
+ label.set_style_sheet(HANDLE_RECORDING_STYLE)
299
+ label.set_text('REC | Ctrl+Alt+R stop | Drag here to move')
300
+ else
301
+ label.set_style_sheet(HANDLE_IDLE_STYLE)
302
+ label.set_text('Ctrl+Alt+R start/stop | Drag here to move')
303
+ end
304
+ end
305
+
306
+ def set_edit_mode(frame, handle, resize_handles, enabled)
307
+ if enabled
308
+ frame.set_window_flag(WINDOW_FLAG_TRANSPARENT_FOR_INPUT, 0)
309
+ frame.set_attribute(WA_TRANSPARENT_FOR_MOUSE_EVENTS, 0)
310
+ handle.show
311
+ resize_handles.each_value(&:show)
312
+ else
313
+ frame.set_window_flag(WINDOW_FLAG_TRANSPARENT_FOR_INPUT, 1)
314
+ frame.set_attribute(WA_TRANSPARENT_FOR_MOUSE_EVENTS, 1)
315
+ handle.hide
316
+ resize_handles.each_value(&:hide)
317
+ end
318
+
319
+ frame.show
320
+ end
321
+
322
+ app = QApplication.new(0, [])
323
+
324
+ frame = QWidget.new do |w|
325
+ w.set_window_flags(floating_flags)
326
+ w.set_attribute(WA_NO_SYSTEM_BACKGROUND, 1)
327
+ w.set_attribute(WA_TRANSLUCENT_BACKGROUND, 1)
328
+ w.set_geometry(260, 180, 1024, 576)
329
+ w.set_style_sheet(FRAME_IDLE_STYLE)
330
+ end
331
+
332
+ saved_position = load_position(position_config_path)
333
+ if saved_position
334
+ frame.set_geometry(
335
+ saved_position[:left],
336
+ saved_position[:top],
337
+ [saved_position[:width], MIN_FRAME_W].max,
338
+ [saved_position[:height], MIN_FRAME_H].max
339
+ )
340
+ end
341
+
342
+ border_overlay = QLabel.new(frame)
343
+ border_overlay.set_attribute(WA_TRANSPARENT_FOR_MOUSE_EVENTS, 1)
344
+ border_overlay.set_style_sheet(BORDER_IDLE_STYLE)
345
+
346
+ handle = QWidget.new(frame)
347
+ handle_label = QLabel.new(handle)
348
+ handle_label.set_alignment(Qt::AlignCenter)
349
+ apply_handle_state(handle_label, false)
350
+
351
+ record_indicator = QLabel.new(frame)
352
+ record_indicator.set_style_sheet(REC_INDICATOR_STYLE)
353
+ record_indicator.hide
354
+
355
+ resize_handles = {
356
+ n: QWidget.new(frame),
357
+ s: QWidget.new(frame),
358
+ e: QWidget.new(frame),
359
+ w: QWidget.new(frame),
360
+ ne: QWidget.new(frame),
361
+ nw: QWidget.new(frame),
362
+ se: QWidget.new(frame),
363
+ sw: QWidget.new(frame)
364
+ }
365
+ resize_handles.each_value { |grip| grip.set_style_sheet(RESIZE_HANDLE_STYLE) }
366
+
367
+ recording = false
368
+ ffmpeg_pid = nil
369
+ temp_output = nil
370
+ last_log = nil
371
+ last_geometry = nil
372
+
373
+ dragging = false
374
+ drag_local_x = 0
375
+ drag_local_y = 0
376
+
377
+ layout_overlays = lambda do
378
+ fw = frame.width
379
+ fh = frame.height
380
+
381
+ handle_w = [[HANDLE_W, fw - (HANDLE_PADDING * 2)].min, 120].max
382
+ border_overlay.set_geometry(0, 0, fw, fh)
383
+ handle.set_geometry(HANDLE_PADDING, HANDLE_PADDING, handle_w, HANDLE_H)
384
+ handle_label.set_geometry(0, 0, handle_w, HANDLE_H)
385
+
386
+ record_indicator.set_geometry(fw - 24, 8, 18, 18)
387
+
388
+ resize_handles[:n].set_geometry(RESIZE_CORNER, 0, [fw - (RESIZE_CORNER * 2), 16].max, RESIZE_EDGE)
389
+ resize_handles[:s].set_geometry(RESIZE_CORNER, fh - RESIZE_EDGE, [fw - (RESIZE_CORNER * 2), 16].max, RESIZE_EDGE)
390
+ resize_handles[:w].set_geometry(0, RESIZE_CORNER, RESIZE_EDGE, [fh - (RESIZE_CORNER * 2), 16].max)
391
+ resize_handles[:e].set_geometry(fw - RESIZE_EDGE, RESIZE_CORNER, RESIZE_EDGE, [fh - (RESIZE_CORNER * 2), 16].max)
392
+ resize_handles[:nw].set_geometry(0, 0, RESIZE_CORNER, RESIZE_CORNER)
393
+ resize_handles[:ne].set_geometry(fw - RESIZE_CORNER, 0, RESIZE_CORNER, RESIZE_CORNER)
394
+ resize_handles[:sw].set_geometry(0, fh - RESIZE_CORNER, RESIZE_CORNER, RESIZE_CORNER)
395
+ resize_handles[:se].set_geometry(fw - RESIZE_CORNER, fh - RESIZE_CORNER, RESIZE_CORNER, RESIZE_CORNER)
396
+ end
397
+
398
+ set_frame_geometry = lambda do |x, y, w, h|
399
+ frame.set_geometry(x, y, [w, MIN_FRAME_W].max, [h, MIN_FRAME_H].max)
400
+ layout_overlays.call
401
+ end
402
+
403
+ move_overlay_to = lambda do |new_frame_x, new_frame_y|
404
+ set_frame_geometry.call(new_frame_x, new_frame_y, frame.width, frame.height)
405
+ end
406
+
407
+ toggle_recording = lambda do
408
+ next unless ffmpeg_available?
409
+
410
+ if recording
411
+ stop_recorder!(ffmpeg_pid)
412
+ ffmpeg_pid = nil
413
+ recording = false
414
+ frame.set_style_sheet(FRAME_IDLE_STYLE)
415
+ border_overlay.set_style_sheet(BORDER_IDLE_STYLE)
416
+ apply_handle_state(handle_label, false)
417
+ record_indicator.hide
418
+ set_edit_mode(frame, handle, resize_handles, true)
419
+ frame.activate_window
420
+ frame.grab_keyboard
421
+
422
+ save_to = choose_output_path(frame)
423
+ if save_to
424
+ FileUtils.mkdir_p(File.dirname(save_to))
425
+ FileUtils.mv(temp_output, save_to)
426
+ handle_label.set_text("Saved: #{save_to}")
427
+ puts "[saved] #{save_to}"
428
+ else
429
+ handle_label.set_text('Save canceled. Temp file kept.')
430
+ puts "[cancelled-save] temp file left at: #{temp_output}"
431
+ end
432
+
433
+ puts "[ffmpeg-log] #{last_log}" if last_log
434
+ else
435
+ ffmpeg_pid, temp_output, last_log, last_geometry = start_recorder(frame)
436
+ recording = true
437
+ frame.set_style_sheet(FRAME_RECORDING_STYLE)
438
+ border_overlay.set_style_sheet(BORDER_RECORDING_STYLE)
439
+ apply_handle_state(handle_label, true)
440
+ record_indicator.show
441
+ set_edit_mode(frame, handle, resize_handles, false)
442
+ puts "[recording] pid=#{ffmpeg_pid} area=#{last_geometry.inspect} tmp=#{temp_output}"
443
+ end
444
+ end
445
+
446
+ handle.on(:mouse_button_press) do |evt|
447
+ next unless evt[:c] == LEFT_BUTTON
448
+
449
+ dragging = true
450
+ drag_local_x = evt[:a]
451
+ drag_local_y = evt[:b]
452
+ end
453
+
454
+ handle.on(:mouse_move) do |evt|
455
+ next unless dragging
456
+ next unless evt[:d].anybits?(LEFT_BUTTON)
457
+
458
+ delta_x = evt[:a] - drag_local_x
459
+ delta_y = evt[:b] - drag_local_y
460
+ move_overlay_to.call(frame.x + delta_x, frame.y + delta_y)
461
+ end
462
+
463
+ handle.on(:mouse_button_release) do |_evt|
464
+ dragging = false
465
+ end
466
+
467
+ resize_state = nil
468
+ resize_handles.each do |dir, grip|
469
+ grip.on(:mouse_button_press) do |evt|
470
+ next unless evt[:c] == LEFT_BUTTON
471
+
472
+ resize_state = {
473
+ dir: dir,
474
+ start_global_x: frame.x + grip.x + evt[:a],
475
+ start_global_y: frame.y + grip.y + evt[:b],
476
+ frame_x: frame.x,
477
+ frame_y: frame.y,
478
+ frame_w: frame.width,
479
+ frame_h: frame.height
480
+ }
481
+ end
482
+
483
+ grip.on(:mouse_move) do |evt|
484
+ next unless resize_state
485
+ next unless evt[:d].anybits?(LEFT_BUTTON)
486
+
487
+ current_global_x = frame.x + grip.x + evt[:a]
488
+ current_global_y = frame.y + grip.y + evt[:b]
489
+ dx = current_global_x - resize_state[:start_global_x]
490
+ dy = current_global_y - resize_state[:start_global_y]
491
+
492
+ new_x = resize_state[:frame_x]
493
+ new_y = resize_state[:frame_y]
494
+ new_w = resize_state[:frame_w]
495
+ new_h = resize_state[:frame_h]
496
+
497
+ new_w = resize_state[:frame_w] + dx if %i[e ne se].include?(resize_state[:dir])
498
+ new_h = resize_state[:frame_h] + dy if %i[s se sw].include?(resize_state[:dir])
499
+
500
+ if %i[w nw sw].include?(resize_state[:dir])
501
+ new_x = resize_state[:frame_x] + dx
502
+ new_w = resize_state[:frame_w] - dx
503
+ if new_w < MIN_FRAME_W
504
+ new_x -= (MIN_FRAME_W - new_w)
505
+ new_w = MIN_FRAME_W
506
+ end
507
+ end
508
+
509
+ if %i[n ne nw].include?(resize_state[:dir])
510
+ new_y = resize_state[:frame_y] + dy
511
+ new_h = resize_state[:frame_h] - dy
512
+ if new_h < MIN_FRAME_H
513
+ new_y -= (MIN_FRAME_H - new_h)
514
+ new_h = MIN_FRAME_H
515
+ end
516
+ end
517
+
518
+ set_frame_geometry.call(new_x, new_y, new_w, new_h)
519
+ end
520
+
521
+ grip.on(:mouse_button_release) do |_evt|
522
+ resize_state = nil
523
+ end
524
+ end
525
+
526
+ # Fallback hotkey when frame has keyboard focus.
527
+ frame.on(:key_press) do |evt|
528
+ key = evt[:a]
529
+ modifiers = evt[:b]
530
+ ctrl_pressed = (modifiers & CTRL_MODIFIER) != 0
531
+ if ctrl_pressed && key == KEY_Q && !recording
532
+ frame.close
533
+ next
534
+ end
535
+
536
+ alt_pressed = (modifiers & ALT_MODIFIER) != 0
537
+ next unless key == KEY_R && ctrl_pressed && alt_pressed
538
+
539
+ toggle_recording.call
540
+ end
541
+
542
+ hotkey_listener = X11GlobalHotkey.start_listener
543
+
544
+ unless ffmpeg_available?
545
+ handle_label.set_text('ffmpeg not found in PATH. Install ffmpeg and restart.')
546
+ end
547
+
548
+ frame.show
549
+ layout_overlays.call
550
+ set_edit_mode(frame, handle, resize_handles, true)
551
+ frame.activate_window
552
+ frame.grab_keyboard
553
+ QApplication.process_events
554
+
555
+ hotkey_status_printed = false
556
+ last_persisted_geometry = [frame.x, frame.y, frame.width, frame.height]
557
+ while frame.is_visible != 0
558
+ QApplication.process_events
559
+
560
+ unless hotkey_status_printed
561
+ if hotkey_listener.nil?
562
+ puts '[hotkey] global Ctrl+Alt+R unavailable, fallback to focused window hotkey'
563
+ hotkey_status_printed = true
564
+ else
565
+ puts '[hotkey] global Ctrl+Alt+R listener active'
566
+ hotkey_status_printed = true
567
+ end
568
+ end
569
+
570
+ toggles = X11GlobalHotkey.poll(hotkey_listener)
571
+ toggles.times { toggle_recording.call }
572
+
573
+ geometry_now = [frame.x, frame.y, frame.width, frame.height]
574
+ if geometry_now != last_persisted_geometry
575
+ save_position(position_config_path, frame)
576
+ last_persisted_geometry = geometry_now
577
+ end
578
+
579
+ sleep(0.01)
580
+ end
581
+
582
+ if recording
583
+ stop_recorder!(ffmpeg_pid)
584
+ ffmpeg_pid = nil
585
+ save_to = choose_output_path(frame)
586
+ if save_to
587
+ FileUtils.mkdir_p(File.dirname(save_to))
588
+ FileUtils.mv(temp_output, save_to)
589
+ puts "[saved-on-exit] #{save_to}"
590
+ else
591
+ puts "[cancelled-save-on-exit] temp file left at: #{temp_output}"
592
+ end
593
+ end
594
+
595
+ X11GlobalHotkey.stop_listener(hotkey_listener)
596
+ save_position(position_config_path, frame)
597
+ app.dispose