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,235 @@
1
+ # frozen_string_literal: true
2
+
3
+ $LOAD_PATH.unshift(File.expand_path('../../lib', __dir__))
4
+ require 'qt'
5
+
6
+ WINDOW_W = 980
7
+ WINDOW_H = 620
8
+ PANEL_W = 280
9
+
10
+ BASE_BG = 'background-color: #f4f4f5; border: 1px solid #d4d4d8;'
11
+ CARD_LIGHT = 'background-color: #ffffff; border: 1px solid #d4d4d8; color: #111827; font-size: 12px;'
12
+ CARD_DARK = 'background-color: #1f2937; border: 1px solid #374151; color: #f3f4f6; font-size: 12px;'
13
+ TITLE_LIGHT = 'background-color: #ffffff; border: 1px solid #d4d4d8; color: #111827; font-size: 16px; font-weight: 800;'
14
+ TITLE_DARK = 'background-color: #111827; border: 1px solid #374151; color: #f9fafb; font-size: 16px; font-weight: 800;'
15
+ BTN_LIGHT = 'background-color: #ffffff; border: 1px solid #a1a1aa; color: #111827; font-size: 12px; font-weight: 700;'
16
+ BTN_DARK = 'background-color: #111827; border: 1px solid #6b7280; color: #f9fafb; font-size: 12px; font-weight: 700;'
17
+ BTN_ACTIVE_LIGHT = 'background-color: #dbeafe; border: 2px solid #3b82f6; color: #111827; ' \
18
+ 'font-size: 12px; font-weight: 800;'
19
+ BTN_ACTIVE_DARK = 'background-color: #1e3a8a; border: 2px solid #60a5fa; color: #f9fafb; ' \
20
+ 'font-size: 12px; font-weight: 800;'
21
+
22
+ app = QApplication.new(0, [])
23
+ window = QWidget.new do |w|
24
+ w.set_window_title('Qt Ruby Component Showcase')
25
+ w.set_geometry(90, 70, WINDOW_W, WINDOW_H)
26
+ end
27
+
28
+ root_bg = QLabel.new(window)
29
+ root_bg.set_geometry(0, 0, WINDOW_W, WINDOW_H)
30
+ root_bg.set_style_sheet(BASE_BG)
31
+
32
+ preview_panel = QLabel.new(window)
33
+ preview_panel.set_geometry(16, 16, WINDOW_W - PANEL_W - 28, WINDOW_H - 32)
34
+
35
+ preview_title = QLabel.new(window)
36
+ preview_title.set_geometry(32, 32, WINDOW_W - PANEL_W - 60, 36)
37
+ preview_title.set_alignment(Qt::AlignCenter)
38
+ preview_title.set_text('Preview Area (QVBoxLayout + Dynamic Widgets)')
39
+
40
+ preview_host = QWidget.new(window)
41
+ preview_host.set_geometry(32, 80, WINDOW_W - PANEL_W - 60, WINDOW_H - 112)
42
+ layout = QVBoxLayout.new(preview_host)
43
+ preview_host.set_layout(layout)
44
+
45
+ side_panel = QLabel.new(window)
46
+ side_panel.set_geometry(WINDOW_W - PANEL_W, 0, PANEL_W, WINDOW_H)
47
+
48
+ status = QLabel.new(window)
49
+ status.set_geometry(WINDOW_W - PANEL_W + 16, 16, PANEL_W - 32, 64)
50
+ status.set_alignment(Qt::AlignCenter)
51
+ status.set_text("Ready\nitems: 0")
52
+
53
+ api_box = QLabel.new(window)
54
+ api_box.set_geometry(WINDOW_W - PANEL_W + 16, 88, PANEL_W - 32, 70)
55
+ api_box.set_alignment(Qt::AlignCenter)
56
+ api_box.set_text('Classes:\nQWidget QLabel QPushButton QVBoxLayout')
57
+
58
+ buttons = [
59
+ { key: :add_label, text: 'ADD LABEL', y_offset: 176 },
60
+ { key: :add_button, text: 'ADD PUSHBUTTON', y_offset: 222 },
61
+ { key: :theme, text: 'TOGGLE THEME', y_offset: 268 },
62
+ { key: :inspect, text: 'INSPECT LAST', y_offset: 314 },
63
+ { key: :remove, text: 'REMOVE LAST', y_offset: 360 },
64
+ { key: :clear, text: 'CLEAR ALL', y_offset: 406 }
65
+ ]
66
+
67
+ buttons.each do |btn|
68
+ view = QPushButton.new(window)
69
+ view.set_geometry(WINDOW_W - PANEL_W + 16, btn[:y_offset], PANEL_W - 32, 36)
70
+ view.set_text(btn[:text])
71
+ btn[:view] = view
72
+ end
73
+
74
+ hint = QLabel.new(window)
75
+ hint.set_geometry(WINDOW_W - PANEL_W + 16, WINDOW_H - 116, PANEL_W - 32, 96)
76
+ hint.set_alignment(Qt::AlignCenter)
77
+ hint.set_text("Mouse controls:\n- Click side buttons\n- Resize window and see layout adapt")
78
+
79
+ items = []
80
+ counter = 1
81
+ dark = false
82
+
83
+ layout_ui = lambda do
84
+ ww = window.width
85
+ wh = window.height
86
+ side_x = ww - PANEL_W
87
+
88
+ root_bg.set_geometry(0, 0, ww, wh)
89
+ preview_panel.set_geometry(16, 16, ww - PANEL_W - 28, wh - 32)
90
+ preview_title.set_geometry(32, 32, ww - PANEL_W - 60, 36)
91
+ preview_host.set_geometry(32, 80, ww - PANEL_W - 60, wh - 112)
92
+
93
+ side_panel.set_geometry(side_x, 0, PANEL_W, wh)
94
+ status.set_geometry(side_x + 16, 16, PANEL_W - 32, 64)
95
+ api_box.set_geometry(side_x + 16, 88, PANEL_W - 32, 70)
96
+ hint.set_geometry(side_x + 16, wh - 116, PANEL_W - 32, 96)
97
+
98
+ buttons.each do |btn|
99
+ btn[:view].set_geometry(side_x + 16, btn[:y_offset], PANEL_W - 32, 36)
100
+ end
101
+ end
102
+
103
+ apply_theme = lambda do
104
+ if dark
105
+ root_bg.set_style_sheet('background-color: #0b1220; border: 1px solid #1f2937;')
106
+ preview_panel.set_style_sheet(CARD_DARK)
107
+ preview_title.set_style_sheet(TITLE_DARK)
108
+ side_panel.set_style_sheet('background-color: #0f172a; border-left: 1px solid #334155;')
109
+ status.set_style_sheet(CARD_DARK)
110
+ api_box.set_style_sheet(CARD_DARK)
111
+ hint.set_style_sheet(CARD_DARK)
112
+ buttons.each { |b| b[:view].set_style_sheet(BTN_DARK) }
113
+ else
114
+ root_bg.set_style_sheet(BASE_BG)
115
+ preview_panel.set_style_sheet(CARD_LIGHT)
116
+ preview_title.set_style_sheet(TITLE_LIGHT)
117
+ side_panel.set_style_sheet('background-color: #f4f4f5; border-left: 1px solid #d4d4d8;')
118
+ status.set_style_sheet(CARD_LIGHT)
119
+ api_box.set_style_sheet(CARD_LIGHT)
120
+ hint.set_style_sheet(CARD_LIGHT)
121
+ buttons.each { |b| b[:view].set_style_sheet(BTN_LIGHT) }
122
+ end
123
+ end
124
+
125
+ flash_button = lambda do |key|
126
+ btn = buttons.find { |b| b[:key] == key }
127
+ return unless btn
128
+
129
+ btn[:view].set_style_sheet(dark ? BTN_ACTIVE_DARK : BTN_ACTIVE_LIGHT)
130
+ QApplication.process_events
131
+ sleep(0.04)
132
+ btn[:view].set_style_sheet(dark ? BTN_DARK : BTN_LIGHT)
133
+ end
134
+
135
+ add_label = lambda do
136
+ label = QLabel.new(preview_host)
137
+ label.set_text("Dynamic QLabel ##{counter}")
138
+ label.set_alignment(Qt::AlignCenter)
139
+ label.set_style_sheet(dark ? CARD_DARK : CARD_LIGHT)
140
+ layout.add_widget(label)
141
+ items << label
142
+ end
143
+
144
+ add_push_button = lambda do
145
+ button = QPushButton.new(preview_host)
146
+ button.set_text("QPushButton ##{counter}")
147
+ layout.add_widget(button)
148
+ items << button
149
+ end
150
+
151
+ refresh_status = lambda do
152
+ status.set_text("Ready\nitems: #{items.length}")
153
+ end
154
+
155
+ apply_label_theme = lambda do
156
+ items.each do |item|
157
+ next unless item.is_a?(QLabel)
158
+
159
+ item.set_style_sheet(dark ? CARD_DARK : CARD_LIGHT)
160
+ end
161
+ end
162
+
163
+ inspect_last_item = lambda do
164
+ last = items.last
165
+ if last
166
+ data = last.q_inspect
167
+ puts "[inspect] #{data}"
168
+ status.set_text("Inspect OK\n#{data[:ruby_class]}")
169
+ else
170
+ status.set_text('Inspect: no items')
171
+ end
172
+ end
173
+
174
+ remove_last_item = lambda do
175
+ last = items.pop
176
+ return unless last
177
+
178
+ layout.remove_widget(last)
179
+ last.hide
180
+ end
181
+
182
+ clear_items = lambda do
183
+ until items.empty?
184
+ item = items.pop
185
+ layout.remove_widget(item)
186
+ item.hide
187
+ end
188
+ end
189
+
190
+ perform_action = lambda do |key|
191
+ case key
192
+ when :add_label
193
+ add_label.call
194
+ counter += 1
195
+ when :add_button
196
+ add_push_button.call
197
+ counter += 1
198
+ when :theme
199
+ dark = !dark
200
+ apply_theme.call
201
+ apply_label_theme.call
202
+ when :inspect
203
+ inspect_last_item.call
204
+ when :remove
205
+ remove_last_item.call
206
+ when :clear
207
+ clear_items.call
208
+ end
209
+
210
+ refresh_status.call unless key == :inspect
211
+ end
212
+
213
+ buttons.each do |btn|
214
+ btn[:view].connect('clicked') do |_checked|
215
+ flash_button.call(btn[:key])
216
+ perform_action.call(btn[:key])
217
+ end
218
+ end
219
+
220
+ apply_theme.call
221
+ refresh_status.call
222
+ layout_ui.call
223
+ window.on(:resize) { |_ev| layout_ui.call }
224
+ window.show
225
+ QApplication.process_events
226
+
227
+ # TODO: Replace manual process_events loop with app.exec + QTimer.
228
+ loop do
229
+ QApplication.process_events
230
+ break if window.is_visible.zero?
231
+
232
+ sleep(0.01)
233
+ end
234
+
235
+ app.dispose
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ $LOAD_PATH.unshift(File.expand_path('../../lib', __dir__))
4
+ require 'qt'
5
+
6
+ CELL = 12
7
+ COLS = 64
8
+ ROWS = 40
9
+ TOOLBAR_HEIGHT = 44
10
+ CANVAS_WIDTH = COLS * CELL
11
+ CANVAS_HEIGHT = ROWS * CELL
12
+ WINDOW_WIDTH = CANVAS_WIDTH
13
+ WINDOW_HEIGHT = TOOLBAR_HEIGHT + CANVAS_HEIGHT
14
+
15
+ LEFT_BUTTON = 1
16
+ RIGHT_BUTTON = 2
17
+
18
+ ERASE_STYLE = 'background-color: #ffffff; border: 1px solid #f1f1f1;'
19
+ TOOLBAR_STYLE = 'background-color: #f7f7f7; border: 1px solid #d8d8d8;'
20
+ STATUS_STYLE = 'background-color: #ffffff; border: 1px solid #c7c7c7; color: #111111; ' \
21
+ 'font-weight: 700; font-size: 12px;'
22
+ CLEAR_STYLE = 'background-color: #ffffff; border: 1px solid #c7c7c7; color: #111111; font-weight: 800; font-size: 12px;'
23
+ PALETTE = [
24
+ { name: 'Black', style: 'background-color: #111111; border: 1px solid #111111;' },
25
+ { name: 'Blue', style: 'background-color: #1e66f5; border: 1px solid #1e66f5;' },
26
+ { name: 'Green', style: 'background-color: #40a02b; border: 1px solid #40a02b;' },
27
+ { name: 'Red', style: 'background-color: #d20f39; border: 1px solid #d20f39;' },
28
+ { name: 'Orange', style: 'background-color: #fe640b; border: 1px solid #fe640b;' }
29
+ ].freeze
30
+
31
+ app = QApplication.new(0, [])
32
+ window = QWidget.new do |w|
33
+ w.set_window_title('Qt Ruby Paint: LMB draw, RMB erase')
34
+ w.set_geometry(80, 80, WINDOW_WIDTH, WINDOW_HEIGHT)
35
+ end
36
+
37
+ toolbar_bg = QLabel.new(window)
38
+ toolbar_bg.set_geometry(0, 0, WINDOW_WIDTH, TOOLBAR_HEIGHT)
39
+ toolbar_bg.set_style_sheet(TOOLBAR_STYLE)
40
+
41
+ status = QLabel.new(window)
42
+ status.set_geometry(10, 8, 260, 28)
43
+ status.set_alignment(Qt::AlignCenter)
44
+ status.set_style_sheet(STATUS_STYLE)
45
+
46
+ swatches = []
47
+ PALETTE.each_with_index do |entry, i|
48
+ swatch = QLabel.new(window)
49
+ swatch.set_geometry(285 + (i * 34), 7, 28, 28)
50
+ swatch.set_style_sheet(entry[:style])
51
+ swatches << swatch
52
+ end
53
+
54
+ clear_button = QLabel.new(window)
55
+ clear_button.set_geometry(285 + (PALETTE.length * 34) + 12, 7, 100, 28)
56
+ clear_button.set_text('CLEAR')
57
+ clear_button.set_alignment(Qt::AlignCenter)
58
+ clear_button.set_style_sheet(CLEAR_STYLE)
59
+
60
+ cells = Array.new(ROWS) { Array.new(COLS) }
61
+ ROWS.times do |row|
62
+ COLS.times do |col|
63
+ pixel = QLabel.new(window)
64
+ pixel.set_geometry(col * CELL, TOOLBAR_HEIGHT + (row * CELL), CELL, CELL)
65
+ pixel.set_style_sheet(ERASE_STYLE)
66
+ cells[row][col] = pixel
67
+ end
68
+ end
69
+
70
+ selected_index = 0
71
+ selected_style = PALETTE[selected_index][:style]
72
+
73
+ refresh_palette = lambda do
74
+ swatches.each_with_index do |swatch, idx|
75
+ border = idx == selected_index ? '3px solid #000000' : '1px solid #999999'
76
+ swatch.set_style_sheet("#{PALETTE[idx][:style]} border: #{border};")
77
+ end
78
+
79
+ status.set_text("Color: #{PALETTE[selected_index][:name]}")
80
+ end
81
+
82
+ clear_canvas = lambda do
83
+ ROWS.times do |row|
84
+ COLS.times do |col|
85
+ cells[row][col].set_style_sheet(ERASE_STYLE)
86
+ end
87
+ end
88
+ end
89
+
90
+ inside = ->(x, y, gx, gy, w, h) { x >= gx && x < gx + w && y >= gy && y < gy + h }
91
+
92
+ paint_at = lambda do |x, y, erase|
93
+ return unless inside.call(x, y, 0, TOOLBAR_HEIGHT, CANVAS_WIDTH, CANVAS_HEIGHT)
94
+
95
+ col = x / CELL
96
+ row = (y - TOOLBAR_HEIGHT) / CELL
97
+ pixel = cells[row][col]
98
+ pixel.set_style_sheet(erase ? ERASE_STYLE : selected_style)
99
+ end
100
+
101
+ window.show
102
+ QApplication.process_events
103
+ refresh_palette.call
104
+
105
+ window.on(:mouse_button_press) do |evt|
106
+ x = evt[:a]
107
+ y = evt[:b]
108
+ button = evt[:c]
109
+
110
+ if button == LEFT_BUTTON
111
+ swatches.each_with_index do |_swatch, idx|
112
+ sx = 285 + (idx * 34)
113
+ next unless inside.call(x, y, sx, 7, 28, 28)
114
+
115
+ selected_index = idx
116
+ selected_style = PALETTE[selected_index][:style]
117
+ refresh_palette.call
118
+ end
119
+
120
+ if inside.call(x, y, 285 + (PALETTE.length * 34) + 12, 7, 100, 28)
121
+ clear_canvas.call
122
+ status.set_text("Color: #{PALETTE[selected_index][:name]} (canvas cleared)")
123
+ end
124
+ end
125
+
126
+ paint_at.call(x, y, button == RIGHT_BUTTON)
127
+ end
128
+
129
+ window.on(:mouse_move) do |evt|
130
+ x = evt[:a]
131
+ y = evt[:b]
132
+ buttons = evt[:d]
133
+
134
+ if buttons.anybits?(LEFT_BUTTON)
135
+ paint_at.call(x, y, false)
136
+ elsif buttons.anybits?(RIGHT_BUTTON)
137
+ paint_at.call(x, y, true)
138
+ end
139
+ end
140
+
141
+ # TODO: Replace manual process_events loop with app.exec + QTimer.
142
+ while window.is_visible != 0
143
+ QApplication.process_events
144
+ sleep(0.005)
145
+ end
146
+
147
+ app.dispose
@@ -0,0 +1,295 @@
1
+ # frozen_string_literal: true
2
+
3
+ $LOAD_PATH.unshift(File.expand_path('../../lib', __dir__))
4
+ require 'qt'
5
+
6
+ COLS = 10
7
+ ROWS = 20
8
+ CELL = 24
9
+ PANEL_WIDTH = 180
10
+ WINDOW_WIDTH = (COLS * CELL) + PANEL_WIDTH
11
+ WINDOW_HEIGHT = ROWS * CELL
12
+
13
+ EMPTY_STYLE = 'background-color: #fafafa; border: 1px solid #e6e6e6;'
14
+ BORDER_STYLE = 'background-color: #f3f3f3; border: 1px solid #d5d5d5;'
15
+ TEXT_STYLE = 'background-color: #ffffff; border: 1px solid #cccccc;'
16
+ BUTTON_STYLE = 'background-color: #ffffff; border: 1px solid #bdbdbd;'
17
+ BUTTON_ACTIVE_STYLE = 'background-color: #dbeafe; border: 1px solid #60a5fa;'
18
+ TITLE_STYLE = 'background-color: #ffffff; border: 1px solid #cccccc; color: #111111; font-weight: 800; font-size: 15px;'
19
+ INFO_STYLE = 'background-color: #ffffff; border: 1px solid #cccccc; color: #111111; font-weight: 700; font-size: 12px;'
20
+ BTN_STYLE = 'background-color: #ffffff; border: 1px solid #bdbdbd; color: #111111; font-weight: 700; font-size: 12px;'
21
+ BTN_ACTIVE_STYLE = 'background-color: #dbeafe; border: 2px solid #2563eb; color: #111111; ' \
22
+ 'font-weight: 800; font-size: 12px;'
23
+
24
+ PIECE_STYLES = [
25
+ 'background-color: #16a34a; border: 1px solid #15803d;',
26
+ 'background-color: #1d4ed8; border: 1px solid #1e40af;',
27
+ 'background-color: #dc2626; border: 1px solid #b91c1c;',
28
+ 'background-color: #ea580c; border: 1px solid #c2410c;',
29
+ 'background-color: #7c3aed; border: 1px solid #6d28d9;',
30
+ 'background-color: #0891b2; border: 1px solid #0e7490;',
31
+ 'background-color: #ca8a04; border: 1px solid #a16207;'
32
+ ].freeze
33
+
34
+ SHAPES = [
35
+ [[0, 1], [1, 1], [2, 1], [3, 1]],
36
+ [[0, 0], [0, 1], [1, 1], [2, 1]],
37
+ [[2, 0], [0, 1], [1, 1], [2, 1]],
38
+ [[1, 0], [2, 0], [0, 1], [1, 1]],
39
+ [[1, 0], [0, 1], [1, 1], [2, 1]],
40
+ [[0, 0], [1, 0], [1, 1], [2, 1]],
41
+ [[1, 0], [2, 0], [1, 1], [2, 1]]
42
+ ].freeze
43
+
44
+ # Rotate shape 90 degrees clockwise around local 4x4 grid.
45
+ def rotate_shape(points)
46
+ points.map { |x, y| [y, 3 - x] }
47
+ end
48
+
49
+ # Normalize points to keep top-left origin after rotation.
50
+ def normalize_shape(points)
51
+ min_x = points.map(&:first).min
52
+ min_y = points.map(&:last).min
53
+ points.map { |x, y| [x - min_x, y - min_y] }
54
+ end
55
+
56
+ app = QApplication.new(0, [])
57
+ window = QWidget.new do |w|
58
+ w.set_window_title('Qt Ruby Tetris')
59
+ w.set_geometry(80, 80, WINDOW_WIDTH, WINDOW_HEIGHT)
60
+ end
61
+
62
+ board_cells = Array.new(ROWS) { Array.new(COLS) }
63
+ board = Array.new(ROWS) { Array.new(COLS, -1) }
64
+
65
+ ROWS.times do |r|
66
+ COLS.times do |c|
67
+ cell = QLabel.new(window)
68
+ cell.set_geometry(c * CELL, r * CELL, CELL, CELL)
69
+ cell.set_style_sheet(EMPTY_STYLE)
70
+ board_cells[r][c] = cell
71
+ end
72
+ end
73
+
74
+ side = QLabel.new(window)
75
+ side.set_geometry(COLS * CELL, 0, PANEL_WIDTH, WINDOW_HEIGHT)
76
+ side.set_style_sheet(BORDER_STYLE)
77
+
78
+ title = QLabel.new(window)
79
+ title.set_geometry((COLS * CELL) + 16, 16, PANEL_WIDTH - 32, 30)
80
+ title.set_alignment(Qt::AlignCenter)
81
+ title.set_text('TETRIS')
82
+ title.set_style_sheet(TITLE_STYLE)
83
+
84
+ score_label = QLabel.new(window)
85
+ score_label.set_geometry((COLS * CELL) + 16, 56, PANEL_WIDTH - 32, 30)
86
+ score_label.set_alignment(Qt::AlignCenter)
87
+ score_label.set_style_sheet(INFO_STYLE)
88
+
89
+ status_label = QLabel.new(window)
90
+ status_label.set_geometry((COLS * CELL) + 16, 96, PANEL_WIDTH - 32, 30)
91
+ status_label.set_alignment(Qt::AlignCenter)
92
+ status_label.set_style_sheet(INFO_STYLE)
93
+
94
+ buttons = [
95
+ { key: :left, text: 'LEFT', x: (COLS * CELL) + 16, y: 150, w: 70, h: 34 },
96
+ { key: :right, text: 'RIGHT', x: (COLS * CELL) + 94, y: 150, w: 70, h: 34 },
97
+ { key: :rotate, text: 'ROTATE', x: (COLS * CELL) + 16, y: 192, w: 148, h: 34 },
98
+ { key: :drop, text: 'DROP', x: (COLS * CELL) + 16, y: 234, w: 148, h: 34 },
99
+ { key: :new, text: 'NEW GAME', x: (COLS * CELL) + 16, y: 276, w: 148, h: 34 }
100
+ ]
101
+
102
+ buttons.each do |btn|
103
+ view = QPushButton.new(window)
104
+ view.set_geometry(btn[:x], btn[:y], btn[:w], btn[:h])
105
+ view.set_text(btn[:text])
106
+ view.set_focus_policy(Qt::NoFocus)
107
+ view.set_style_sheet(BTN_STYLE)
108
+ btn[:view] = view
109
+ end
110
+
111
+ current = nil
112
+ score = 0
113
+ lines = 0
114
+ fall_interval = 0.45
115
+ last_fall = Time.now
116
+
117
+ valid_position = lambda do |piece, dx, dy, shape = nil|
118
+ test = shape || piece[:shape]
119
+ test.all? do |x, y|
120
+ nx = piece[:x] + x + dx
121
+ ny = piece[:y] + y + dy
122
+ next false if nx.negative? || nx >= COLS || ny >= ROWS
123
+ next true if ny.negative?
124
+
125
+ board[ny][nx] == -1
126
+ end
127
+ end
128
+
129
+ spawn_piece = lambda do
130
+ shape_idx = rand(SHAPES.length)
131
+ {
132
+ x: 3,
133
+ y: -1,
134
+ color: shape_idx,
135
+ shape: SHAPES[shape_idx].map(&:dup)
136
+ }
137
+ end
138
+
139
+ lock_piece = lambda do |piece|
140
+ piece[:shape].each do |x, y|
141
+ bx = piece[:x] + x
142
+ by = piece[:y] + y
143
+ next if by.negative?
144
+
145
+ board[by][bx] = piece[:color]
146
+ end
147
+ end
148
+
149
+ clear_lines = lambda do
150
+ kept = board.reject { |row| row.all? { |cell| cell >= 0 } }
151
+ removed = ROWS - kept.length
152
+ removed.times { kept.unshift(Array.new(COLS, -1)) }
153
+
154
+ ROWS.times { |r| board[r] = kept[r] }
155
+
156
+ if removed.positive?
157
+ lines += removed
158
+ score += case removed
159
+ when 1 then 100
160
+ when 2 then 250
161
+ when 3 then 450
162
+ else 700
163
+ end
164
+ end
165
+ end
166
+
167
+ restart = lambda do
168
+ ROWS.times do |r|
169
+ COLS.times do |c|
170
+ board[r][c] = -1
171
+ end
172
+ end
173
+
174
+ score = 0
175
+ lines = 0
176
+ fall_interval = 0.45
177
+ current = spawn_piece.call
178
+ status_label.set_text('RUNNING')
179
+ last_fall = Time.now
180
+ end
181
+
182
+ paint = lambda do
183
+ ROWS.times do |r|
184
+ COLS.times do |c|
185
+ v = board[r][c]
186
+ board_cells[r][c].set_style_sheet(v >= 0 ? PIECE_STYLES[v] : EMPTY_STYLE)
187
+ end
188
+ end
189
+
190
+ if current
191
+ current[:shape].each do |x, y|
192
+ bx = current[:x] + x
193
+ by = current[:y] + y
194
+ next if by.negative? || bx.negative? || bx >= COLS || by >= ROWS
195
+
196
+ board_cells[by][bx].set_style_sheet(PIECE_STYLES[current[:color]])
197
+ end
198
+ end
199
+
200
+ score_label.set_text("Score: #{score} Lines: #{lines}")
201
+ end
202
+
203
+ press_button = lambda do |name|
204
+ button = buttons.find { |b| b[:key] == name }
205
+ return unless button
206
+
207
+ button[:view].set_style_sheet(BTN_ACTIVE_STYLE)
208
+ QApplication.process_events
209
+ sleep(0.03)
210
+ button[:view].set_style_sheet(BTN_STYLE)
211
+ end
212
+
213
+ perform_action = lambda do |action|
214
+ return restart.call if action == :new
215
+ return if current.nil?
216
+
217
+ case action
218
+ when :left
219
+ current[:x] -= 1 if valid_position.call(current, -1, 0)
220
+ when :right
221
+ current[:x] += 1 if valid_position.call(current, 1, 0)
222
+ when :rotate
223
+ rotated = normalize_shape(rotate_shape(current[:shape]))
224
+ current[:shape] = rotated if valid_position.call(current, 0, 0, rotated)
225
+ when :drop
226
+ current[:y] += 1 while valid_position.call(current, 0, 1)
227
+ lock_piece.call(current)
228
+ clear_lines.call
229
+ current = spawn_piece.call
230
+ unless valid_position.call(current, 0, 0)
231
+ status_label.set_text('GAME OVER')
232
+ current = nil
233
+ end
234
+ end
235
+ end
236
+
237
+ trigger_action = lambda do |action|
238
+ press_button.call(action) if %i[left right rotate drop new].include?(action)
239
+ perform_action.call(action)
240
+ end
241
+
242
+ action_for_key = lambda do |key_code|
243
+ case key_code
244
+ when Qt::KeyLeft then :left
245
+ when Qt::KeyRight then :right
246
+ when Qt::KeyUp then :rotate
247
+ when Qt::KeyDown, Qt::KeySpace then :drop
248
+ when Qt::KeyN then :new
249
+ end
250
+ end
251
+
252
+ handle_key_event = lambda do |ev|
253
+ action = action_for_key.call(ev[:a])
254
+ trigger_action.call(action) if action
255
+ end
256
+
257
+ buttons.each do |btn|
258
+ btn[:view].connect('clicked') do |_checked|
259
+ trigger_action.call(btn[:key])
260
+ end
261
+ end
262
+
263
+ window.on(:key_press) { |ev| handle_key_event.call(ev) }
264
+
265
+ restart.call
266
+ window.show
267
+
268
+ # TODO: Replace manual game loop polling with app.exec + QTimer tick/update.
269
+ loop do
270
+ QApplication.process_events
271
+ break if window.is_visible.zero?
272
+
273
+ if current && Time.now - last_fall >= fall_interval
274
+ if valid_position.call(current, 0, 1)
275
+ current[:y] += 1
276
+ else
277
+ lock_piece.call(current)
278
+ clear_lines.call
279
+ current = spawn_piece.call
280
+ unless valid_position.call(current, 0, 0)
281
+ status_label.set_text('GAME OVER')
282
+ current = nil
283
+ end
284
+
285
+ fall_interval = [0.12, 0.45 - (lines * 0.01)].max
286
+ end
287
+
288
+ last_fall = Time.now
289
+ end
290
+
291
+ paint.call
292
+ sleep(0.01)
293
+ end
294
+
295
+ app.dispose