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,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Qt
4
+ # Normalizes Ruby strings for bridge text paths.
5
+ module StringCodec
6
+ module_function
7
+
8
+ REPLACEMENT_CHAR = "\uFFFD"
9
+
10
+ def to_qt_text(value)
11
+ text = value.to_s
12
+ return normalize_binary_as_utf8(text) if text.encoding == Encoding::ASCII_8BIT
13
+
14
+ normalize_encoded_text(text)
15
+ end
16
+
17
+ def from_qt_text(value)
18
+ normalize_binary_as_utf8(value.to_s)
19
+ end
20
+
21
+ def binary_bytes?(value)
22
+ return false unless value.is_a?(String)
23
+ return false unless value.encoding == Encoding::ASCII_8BIT
24
+
25
+ !value.dup.force_encoding(Encoding::UTF_8).valid_encoding?
26
+ end
27
+
28
+ def normalize_encoded_text(text)
29
+ text.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: REPLACEMENT_CHAR)
30
+ rescue Encoding::UndefinedConversionError, Encoding::InvalidByteSequenceError
31
+ normalize_binary_as_utf8(text.b)
32
+ end
33
+
34
+ def normalize_binary_as_utf8(text)
35
+ utf8 = text.dup.force_encoding(Encoding::UTF_8)
36
+ utf8.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: REPLACEMENT_CHAR)
37
+ rescue Encoding::UndefinedConversionError, Encoding::InvalidByteSequenceError
38
+ text.to_s.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: REPLACEMENT_CHAR)
39
+ end
40
+
41
+ module_function :normalize_encoded_text, :normalize_binary_as_utf8
42
+ private_class_method :normalize_encoded_text, :normalize_binary_as_utf8
43
+ end
44
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Qt
6
+ # Encodes/decodes Ruby values for QVariant bridge transport.
7
+ module VariantCodec
8
+ module_function
9
+
10
+ PREFIX = 'qtv:'
11
+ DIRECT_ENCODERS = {
12
+ Integer => ->(v) { ['int', v.to_s] },
13
+ Float => ->(v) { ['float', v.to_s] },
14
+ String => lambda { |v|
15
+ if StringCodec.binary_bytes?(v)
16
+ ['ba', base64_encode(v)]
17
+ else
18
+ ['str', base64_encode(StringCodec.to_qt_text(v))]
19
+ end
20
+ }
21
+ }.freeze
22
+ JSON_ENCODABLE_CLASSES = [Array, Hash].freeze
23
+
24
+ def encode(value)
25
+ return "#{PREFIX}nil" if value.nil?
26
+ return encode_boolean(value) if [true, false].include?(value)
27
+
28
+ tag, payload = encoded_tag_and_payload(value)
29
+ "#{PREFIX}#{tag}:#{payload}"
30
+ end
31
+
32
+ def decode(value)
33
+ raw = value.to_s
34
+ return raw unless raw.start_with?(PREFIX)
35
+ return nil if raw == "#{PREFIX}nil"
36
+
37
+ tag, payload = raw.delete_prefix(PREFIX).split(':', 2)
38
+ return raw if tag.nil? || payload.nil?
39
+
40
+ decode_typed_payload(tag, payload, raw)
41
+ rescue ArgumentError, JSON::ParserError
42
+ raw
43
+ end
44
+
45
+ def encode_boolean(value)
46
+ "#{PREFIX}bool:#{value ? 1 : 0}"
47
+ end
48
+
49
+ def decode_typed_payload(tag, payload, raw)
50
+ case tag
51
+ when 'bool' then payload == '1'
52
+ when 'int' then Integer(payload, 10)
53
+ when 'float' then Float(payload)
54
+ when 'str' then StringCodec.from_qt_text(base64_decode(payload))
55
+ when 'ba' then base64_decode(payload).b
56
+ when 'json' then JSON.parse(StringCodec.from_qt_text(base64_decode(payload)))
57
+ else raw
58
+ end
59
+ end
60
+
61
+ def base64_encode(value)
62
+ [value].pack('m0')
63
+ end
64
+
65
+ def base64_decode(value)
66
+ value.unpack1('m0')
67
+ end
68
+
69
+ def encoded_tag_and_payload(value)
70
+ direct = DIRECT_ENCODERS[value.class]
71
+ return direct.call(value) if direct
72
+
73
+ return ['json', base64_encode(JSON.generate(value))] if JSON_ENCODABLE_CLASSES.any? { |k| value.is_a?(k) }
74
+
75
+ ['str', base64_encode(value.to_s)]
76
+ end
77
+ end
78
+ end
data/lib/qt/version.rb ADDED
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Qt
4
+ VERSION = '0.1.0'
5
+ end
data/lib/qt.rb ADDED
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rbconfig'
4
+
5
+ ROOT = File.expand_path('..', __dir__)
6
+ GENERATOR = File.join(ROOT, 'scripts', 'generate_bridge.rb')
7
+ GENERATED_DIR = File.join(ROOT, 'build', 'generated')
8
+ GENERATED_WIDGETS = File.join(GENERATED_DIR, 'widgets.rb')
9
+ GENERATED_API = File.join(GENERATED_DIR, 'bridge_api.rb')
10
+ GENERATED_CONSTANTS = File.join(GENERATED_DIR, 'constants.rb')
11
+
12
+ unless File.exist?(GENERATED_WIDGETS) && File.exist?(GENERATED_API) && File.exist?(GENERATED_CONSTANTS)
13
+ ok = system(RbConfig.ruby, GENERATOR)
14
+ raise 'Failed to generate Qt Ruby bindings. Run: bundle exec rake compile' unless ok
15
+ end
16
+
17
+ require_relative 'qt/version'
18
+ require_relative 'qt/errors'
19
+ require_relative 'qt/constants'
20
+ require_relative 'qt/string_codec'
21
+ require_relative 'qt/date_time_codec'
22
+ require_relative 'qt/key_sequence_codec'
23
+ require_relative 'qt/variant_codec'
24
+ require_relative 'qt/inspectable'
25
+ require_relative 'qt/children_tracking'
26
+ require_relative 'qt/application_lifecycle'
27
+ require_relative 'qt/bridge'
28
+ require_relative 'qt/native'
29
+ require_relative 'qt/event_runtime_dispatch'
30
+ require_relative 'qt/event_runtime_qobject_methods'
31
+ require_relative 'qt/event_runtime'
32
+ require GENERATED_WIDGETS
33
+ require_relative 'qt/shortcut_compat'
34
+ Qt::GeneratedConstantsRuntime.apply_generated_scoped_constants!(Qt)
35
+
36
+ # Root namespace for all Qt Ruby bindings.
37
+ module Qt
38
+ end
39
+
40
+ Qt.constants(false)
41
+ .grep(/\AQ[A-Z]\w*\z/)
42
+ .sort
43
+ .each do |qt_const|
44
+ next if Object.const_defined?(qt_const, false)
45
+
46
+ Object.const_set(qt_const, Qt.const_get(qt_const))
47
+ end
@@ -0,0 +1,267 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Collects enum/typedef names that should be cast as integers for FFI.
4
+ class IntCastTypeCollector
5
+ INTEGER_ALIAS_PATTERN = /\A(?:unsigned\s+|signed\s+)?(?:char|short|int|long|long long)\z/
6
+
7
+ def initialize(ast)
8
+ @ast = ast
9
+ @types = Set.new
10
+ end
11
+
12
+ def collect
13
+ walk_ast_scoped(@ast) do |node, scope|
14
+ name = node['name']
15
+ next if name.nil? || name.empty?
16
+
17
+ qualified = (scope + [name]).join('::')
18
+ ast_append_int_cast_type!(@types, INTEGER_ALIAS_PATTERN, node, qualified)
19
+ end
20
+ @types
21
+ end
22
+ end
23
+
24
+ # Records method/constructor declarations and effective access for a class AST node.
25
+ class AstClassMemberRecorder
26
+ def initialize(class_name, methods_by_class, ctors_by_class, ctor_decls_by_class)
27
+ @class_name = class_name
28
+ @methods_by_class = methods_by_class
29
+ @ctors_by_class = ctors_by_class
30
+ @ctor_decls_by_class = ctor_decls_by_class
31
+ end
32
+
33
+ def record(node)
34
+ current_access = node['tagUsed'] == 'struct' ? 'public' : 'private'
35
+ method_decl_count = ctor_decl_count = 0
36
+ Array(node['inner']).each do |inner|
37
+ current_access, method_decl_count, ctor_decl_count = record_inner(
38
+ inner, current_access, method_decl_count, ctor_decl_count
39
+ )
40
+ end
41
+ [method_decl_count, ctor_decl_count]
42
+ end
43
+
44
+ private
45
+
46
+ def record_inner(inner, current_access, method_decl_count, ctor_decl_count)
47
+ return [inner['access'] || current_access, method_decl_count, ctor_decl_count] if inner['kind'] == 'AccessSpecDecl'
48
+ return [current_access, method_decl_count + 1, ctor_decl_count] if record_method_member?(inner, current_access)
49
+ return [current_access, method_decl_count, ctor_decl_count + 1] if record_constructor_member?(inner, current_access)
50
+
51
+ [current_access, method_decl_count, ctor_decl_count]
52
+ end
53
+
54
+ def record_method_member?(inner, current_access)
55
+ return false unless inner['kind'] == 'CXXMethodDecl' && inner['name']
56
+
57
+ access = inner['access'] || current_access
58
+ @methods_by_class[@class_name][inner['name']] << inner.merge('__effective_access' => access)
59
+ true
60
+ end
61
+
62
+ def record_constructor_member?(inner, current_access)
63
+ return false unless inner['kind'] == 'CXXConstructorDecl' && inner['name']
64
+
65
+ @ctors_by_class[@class_name] << inner['name']
66
+ @ctor_decls_by_class[@class_name] << inner.merge('__effective_access' => current_access)
67
+ true
68
+ end
69
+ end
70
+
71
+ def pkg_config_cflags
72
+ cflags = `pkg-config --cflags Qt6Widgets 2>/dev/null`.strip
73
+ raise 'pkg-config Qt6Widgets is required' if cflags.empty?
74
+
75
+ cflags
76
+ end
77
+
78
+ def ast_dump
79
+ cflags = timed('pkg_config_cflags') { pkg_config_cflags }
80
+
81
+ Tempfile.create(['qt_ruby_probe', '.cpp']) do |file|
82
+ required_includes(GENERATOR_SCOPE).each { |inc| file.write("#include <#{inc}>\n") }
83
+ file.flush
84
+
85
+ cmd = "clang++ -std=c++17 -x c++ -Xclang -ast-dump=json -fsyntax-only #{cflags} #{file.path}"
86
+ out = timed('clang_ast_dump') { `#{cmd}` }
87
+ raise "clang AST dump failed: #{cmd}" unless Process.last_status.success?
88
+
89
+ timed('ast_json_parse') { JSON.parse(out, max_nesting: false) }
90
+ end
91
+ end
92
+
93
+ def walk_ast(node, &)
94
+ return unless node.is_a?(Hash)
95
+
96
+ yield node
97
+ Array(node['inner']).each { |child| walk_ast(child, &) }
98
+ end
99
+
100
+ def walk_ast_scoped(node, scope = [], &)
101
+ return unless node.is_a?(Hash)
102
+
103
+ local_scope = scope
104
+ name = node['name']
105
+ local_scope = scope + [name] if name && !name.empty? && %w[NamespaceDecl CXXRecordDecl].include?(node['kind'])
106
+
107
+ yield node, local_scope
108
+ Array(node['inner']).each { |child| walk_ast_scoped(child, local_scope, &) }
109
+ end
110
+
111
+ def ast_append_int_cast_type!(types, integer_alias_pattern, node, qualified)
112
+ case node['kind']
113
+ when 'EnumDecl'
114
+ types << qualified
115
+ when 'TypedefDecl', 'TypeAliasDecl'
116
+ aliased = node.dig('type', 'qualType').to_s.strip
117
+ return if aliased.empty?
118
+
119
+ types << qualified if aliased.match?(integer_alias_pattern)
120
+ types << qualified if aliased.include?('QFlags<')
121
+ end
122
+ end
123
+
124
+ def ast_int_cast_type_set(ast)
125
+ @ast_int_cast_type_set_cache ||= {}.compare_by_identity
126
+ return @ast_int_cast_type_set_cache[ast] if @ast_int_cast_type_set_cache.key?(ast)
127
+
128
+ @ast_int_cast_type_set_cache[ast] = IntCastTypeCollector.new(ast).collect
129
+ end
130
+
131
+ def collect_class_api(ast, class_name)
132
+ index = ast_class_index(ast)
133
+ methods = index[:methods_by_class].fetch(class_name, {}).keys
134
+ ctors = index[:ctors_by_class].fetch(class_name, [])
135
+ { methods: methods, constructors: ctors }
136
+ end
137
+
138
+ def normalize_cpp_type_name(raw)
139
+ return nil if raw.nil? || raw.empty?
140
+
141
+ name = raw.dup
142
+ name = name.sub(/\A(class|struct)\s+/, '')
143
+ name = name.split('<').first
144
+ name = name.split(/\s+/).first
145
+ name = name.split('::').last
146
+ name&.strip
147
+ end
148
+
149
+ def ast_record_base_classes(node, class_name, bases_by_class)
150
+ Array(node['bases']).each do |base|
151
+ type_info = base['type'] || {}
152
+ raw = type_info['desugaredQualType'] || type_info['qualType']
153
+ parsed_base = normalize_cpp_type_name(raw)
154
+ bases_by_class[class_name] << parsed_base if parsed_base && !parsed_base.empty?
155
+ end
156
+ end
157
+
158
+ def ast_record_class_members(node, class_name, methods_by_class, ctors_by_class, ctor_decls_by_class)
159
+ AstClassMemberRecorder.new(
160
+ class_name, methods_by_class, ctors_by_class, ctor_decls_by_class
161
+ ).record(node)
162
+ end
163
+
164
+ def ast_class_index_method_collections
165
+ {
166
+ methods_by_class: Hash.new { |h, k| h[k] = Hash.new { |h2, k2| h2[k2] = [] } },
167
+ bases_by_class: Hash.new { |h, k| h[k] = [] }
168
+ }
169
+ end
170
+
171
+ def ast_class_index_constructor_collections
172
+ {
173
+ ctors_by_class: Hash.new { |h, k| h[k] = [] },
174
+ ctor_decls_by_class: Hash.new { |h, k| h[k] = [] },
175
+ abstract_by_class: Hash.new(false)
176
+ }
177
+ end
178
+
179
+ def ast_class_index_collections
180
+ ast_class_index_method_collections.merge(ast_class_index_constructor_collections)
181
+ end
182
+
183
+ def init_ast_class_index_data
184
+ ast_class_index_collections.merge(method_decl_count: 0, ctor_decl_count: 0)
185
+ end
186
+
187
+ def ast_index_track_record_decl(node, data)
188
+ class_name = node['name']
189
+ return if class_name.nil? || class_name.empty?
190
+
191
+ data[:abstract_by_class][class_name] ||= node.dig('definitionData', 'isAbstract') == true
192
+ ast_record_base_classes(node, class_name, data[:bases_by_class])
193
+ method_count, ctor_count = ast_record_class_members(
194
+ node, class_name, data[:methods_by_class], data[:ctors_by_class], data[:ctor_decls_by_class]
195
+ )
196
+ data[:method_decl_count] += method_count
197
+ data[:ctor_decl_count] += ctor_count
198
+ end
199
+
200
+ def finalize_ast_class_index!(data)
201
+ data[:bases_by_class].each_value(&:uniq!)
202
+ data[:ctors_by_class].each_value(&:uniq!)
203
+ debug_log("ast_class_index classes=#{data[:methods_by_class].length} method_decls=#{data[:method_decl_count]}")
204
+ debug_log("ast_class_index ctor_decls=#{data[:ctor_decl_count]}")
205
+ data.slice(:methods_by_class, :bases_by_class, :ctors_by_class, :ctor_decls_by_class, :abstract_by_class)
206
+ end
207
+
208
+ def ast_class_index(ast)
209
+ @ast_class_index_cache ||= {}.compare_by_identity
210
+ return @ast_class_index_cache[ast] if @ast_class_index_cache.key?(ast)
211
+
212
+ data = init_ast_class_index_data
213
+
214
+ timed('ast_class_index_build') do
215
+ walk_ast(ast) do |node|
216
+ next unless node['kind'] == 'CXXRecordDecl'
217
+
218
+ ast_index_track_record_decl(node, data)
219
+ end
220
+ end
221
+
222
+ @ast_class_index_cache[ast] = finalize_ast_class_index!(data)
223
+ end
224
+
225
+ def collect_method_decls(ast, class_name, method_name)
226
+ index = ast_class_index(ast)
227
+ index[:methods_by_class].dig(class_name, method_name) || []
228
+ end
229
+
230
+ def collect_method_decls_with_bases(ast, class_name, method_name, visited = {})
231
+ return [] if class_name.nil? || class_name.empty? || visited[class_name]
232
+
233
+ visited[class_name] = true
234
+ own = collect_method_decls(ast, class_name, method_name)
235
+ return own unless own.empty?
236
+
237
+ collect_class_bases(ast, class_name).flat_map do |base|
238
+ collect_method_decls_with_bases(ast, base, method_name, visited)
239
+ end
240
+ end
241
+
242
+ def collect_class_bases(ast, class_name)
243
+ index = ast_class_index(ast)
244
+ Array(index[:bases_by_class][class_name]).uniq
245
+ end
246
+
247
+ def collect_constructor_decls(ast, class_name)
248
+ index = ast_class_index(ast)
249
+ Array(index[:ctor_decls_by_class][class_name])
250
+ end
251
+
252
+ def abstract_class?(ast, class_name)
253
+ index = ast_class_index(ast)
254
+ index[:abstract_by_class][class_name] == true
255
+ end
256
+
257
+ def class_inherits?(ast, class_name, ancestor, visited = {})
258
+ return false if class_name.nil? || class_name.empty? || visited[class_name]
259
+ return true if class_name == ancestor
260
+
261
+ visited[class_name] = true
262
+ collect_class_bases(ast, class_name).any? { |base| class_inherits?(ast, base, ancestor, visited) }
263
+ end
264
+
265
+ def class_has_method?(ast, class_name, method_name)
266
+ collect_class_api(ast, class_name)[:methods].include?(method_name)
267
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Resolves auto-generated methods for a class spec while tracking counters.
4
+ class AutoMethodSpecResolver
5
+ def initialize(spec:, auto_mode:, existing_names:, resolve_method:)
6
+ @spec = spec
7
+ @auto_mode = auto_mode
8
+ @existing_names = existing_names
9
+ @resolve_method = resolve_method
10
+ end
11
+
12
+ def resolve(entry, skipped:, resolved:)
13
+ qt_name = entry_name(entry)
14
+ return [nil, skipped + 1, resolved] if existing_names.include?(qt_name)
15
+
16
+ method, skipped_entry = resolve_entry(entry)
17
+ return [nil, skipped + 1, resolved] if skipped_entry
18
+
19
+ [method, skipped, resolved + 1]
20
+ end
21
+
22
+ private
23
+
24
+ attr_reader :spec, :auto_mode, :existing_names, :resolve_method
25
+
26
+ def entry_name(entry)
27
+ entry.is_a?(String) ? entry : entry[:qt_name]
28
+ end
29
+
30
+ def resolve_entry(entry)
31
+ method = resolve_method.call(entry)
32
+ return [method, false] unless method.nil?
33
+ return [nil, true] if auto_mode == :all
34
+
35
+ raise "Failed to auto-resolve #{spec[:qt_class]}##{entry_name(entry)}"
36
+ end
37
+ end