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,759 @@
1
+ # frozen_string_literal: true
2
+
3
+ $LOAD_PATH.unshift(File.expand_path('../../lib', __dir__))
4
+ require 'date'
5
+ require 'json'
6
+ require 'open3'
7
+ require 'time'
8
+ require 'qt'
9
+
10
+ begin
11
+ require 'timetrap'
12
+ rescue LoadError
13
+ # CLI fallback when gem is not available.
14
+ end
15
+
16
+ WINDOW_W = 1380
17
+ WINDOW_H = 860
18
+ SIDEBAR_W = 220
19
+ TOPBAR_H = 56
20
+ CONTENT_X = SIDEBAR_W + 14
21
+ CONTENT_W = WINDOW_W - CONTENT_X - 14
22
+ LEFT_BUTTON = 1
23
+ PROJECT_SLOT_COUNT = 14
24
+
25
+ TIMETRAP_BIN = ENV.fetch('TIMETRAP_BIN', 't')
26
+ TIMETRAP_API = defined?(Timetrap::Entry) && defined?(Timetrap::Timer)
27
+ DEBUG_UI = ENV['TIMETRAP_UI_DEBUG'] == '1'
28
+
29
+ BG_APP = 'background-color: #eef2f5; border: 1px solid #d6dde5;'
30
+ BG_SIDEBAR = 'background-color: #f7f9fb; border-right: 1px solid #d9e0e7;'
31
+ BG_TOPBAR = 'background-color: #ffffff; border: 1px solid #d9e0e7;'
32
+ CARD = 'background-color: #ffffff; border: 1px solid #d9e0e7; color: #111827;'
33
+ TITLE = 'background-color: #ffffff; border: 0px; color: #111827; font-size: 18px; font-weight: 800;'
34
+ MUTED = 'background-color: #ffffff; border: 0px; color: #6b7280; font-size: 12px;'
35
+ SIDE_ITEM = 'background-color: #f7f9fb; border: 0px; color: #334155; font-size: 14px; font-weight: 700;'
36
+ SIDE_ACTIVE = 'background-color: #eaf3ff; border: 1px solid #bfdbfe; color: #0f172a; font-size: 14px; font-weight: 800;'
37
+ PROJ_ITEM = 'background-color: #f7f9fb; border: 1px solid #e2e8f0; color: #334155; font-size: 12px; font-weight: 700;'
38
+ PROJ_ACTIVE = 'background-color: #dbeafe; border: 2px solid #60a5fa; color: #0f172a; font-size: 12px; font-weight: 800;'
39
+ INPUT_LIKE = 'background-color: #ffffff; border: 1px solid #cfd8e3; color: #111827; font-size: 14px; padding-left: 8px;'
40
+ BTN_PRIMARY = 'background-color: #0ea5e9; border: 1px solid #0284c7; color: #ffffff; font-size: 14px; font-weight: 800;'
41
+ BTN_DANGER = 'background-color: #ef4444; border: 1px solid #dc2626; color: #ffffff; font-size: 14px; font-weight: 800;'
42
+ BTN_GHOST = 'background-color: #ffffff; border: 1px solid #cfd8e3; color: #0f172a; font-size: 13px; font-weight: 700;'
43
+ BTN_ACTIVE = 'background-color: #dbeafe; border: 2px solid #3b82f6; color: #0f172a; font-size: 13px; font-weight: 800;'
44
+ AREA_STYLE = <<~QSS.tr("\n", ' ')
45
+ QScrollArea {
46
+ background-color: #f3f6fa;
47
+ border: 1px solid #d9e0e7;
48
+ }
49
+ QScrollBar:vertical {
50
+ background: #eef2f6;
51
+ width: 12px;
52
+ margin: 2px;
53
+ border: 1px solid #d5dde7;
54
+ border-radius: 6px;
55
+ }
56
+ QScrollBar::handle:vertical {
57
+ background: #9aa8b8;
58
+ min-height: 28px;
59
+ border-radius: 5px;
60
+ }
61
+ QScrollBar::handle:vertical:hover {
62
+ background: #7f90a3;
63
+ }
64
+ QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {
65
+ height: 0px;
66
+ background: transparent;
67
+ border: none;
68
+ }
69
+ QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical {
70
+ background: transparent;
71
+ }
72
+ QSS
73
+ WEEK_STYLE = 'background-color: #dce3ea; border: 1px solid #c8d1db; color: #111827; ' \
74
+ 'font-size: 16px; font-weight: 800; padding-left: 12px;'
75
+ DAY_STYLE = 'background-color: #ecf1f6; border: 1px solid #d5dee8; color: #5b6776; ' \
76
+ 'font-size: 13px; font-weight: 700; padding-left: 12px;'
77
+ PROJECT_ROW = 'background-color: #ffffff; border: 1px solid #d8e0ea; color: #0f172a; ' \
78
+ 'font-size: 14px; font-weight: 700; padding-left: 12px; text-align: left;'
79
+ DETAIL_ROW = 'background-color: #f9fbfd; border: 1px solid #e3e8ef; color: #334155; ' \
80
+ 'font-size: 12px; padding-left: 24px;'
81
+
82
+ # Builds filtered/grouped timetrap entries for UI rendering.
83
+ class TimetrapRenderData
84
+ def initialize(split_sheet:, week_start_for:)
85
+ @split_sheet = split_sheet
86
+ @week_start_for = week_start_for
87
+ end
88
+
89
+ def filtered_entries(entries, selected_project)
90
+ return entries.dup if selected_project == '* ALL'
91
+
92
+ entries.select { |entry| @split_sheet.call(entry[:sheet]).first == selected_project }
93
+ end
94
+
95
+ def group_by_week(entries)
96
+ entries.each_with_object({}) do |entry, by_week|
97
+ week = @week_start_for.call(entry)
98
+ (by_week[week] ||= []) << entry
99
+ end
100
+ end
101
+
102
+ def filtered_week_groups(entries, selected_project)
103
+ filtered = filtered_entries(entries, selected_project)
104
+ [filtered, group_by_week(filtered.last(260).reverse)]
105
+ end
106
+
107
+ def group_by_day(entries)
108
+ entries.each_with_object({}) do |entry, by_day|
109
+ day = (entry[:start_time] || Time.now).to_date
110
+ (by_day[day] ||= []) << entry
111
+ end
112
+ end
113
+
114
+ def project_groups(entries)
115
+ by_project = entries.each_with_object({}) do |entry, grouped|
116
+ project, task = @split_sheet.call(entry[:sheet])
117
+ key = "#{project}\u0000#{task}"
118
+ (grouped[key] ||= { project: project, task: task, entries: [] })[:entries] << entry
119
+ end
120
+ by_project.values.sort_by { |group| [group[:project].downcase, group[:task].downcase] }
121
+ end
122
+ end
123
+
124
+ app = QApplication.new(0, [])
125
+ window = QWidget.new do |w|
126
+ w.set_window_title('Timetrap UI (Clockify-like)')
127
+ w.set_geometry(40, 40, WINDOW_W, WINDOW_H)
128
+ end
129
+
130
+ root = QLabel.new(window)
131
+ root.set_geometry(0, 0, WINDOW_W, WINDOW_H)
132
+ root.set_style_sheet(BG_APP)
133
+
134
+ sidebar = QLabel.new(window)
135
+ sidebar.set_geometry(0, 0, SIDEBAR_W, WINDOW_H)
136
+ sidebar.set_style_sheet(BG_SIDEBAR)
137
+
138
+ logo = QLabel.new(window)
139
+ logo.set_geometry(18, 12, SIDEBAR_W - 36, 34)
140
+ logo.set_alignment(Qt::AlignCenter)
141
+ logo.set_style_sheet(TITLE)
142
+ logo.set_text('clockify-ish / timetrap')
143
+
144
+ side_items = [
145
+ { key: :tracker, text: 'TIME TRACKER', y: 66 },
146
+ { key: :calendar, text: 'CALENDAR', y: 112 },
147
+ { key: :dashboard, text: 'DASHBOARD', y: 186 },
148
+ { key: :reports, text: 'REPORTS', y: 232 }
149
+ ]
150
+
151
+ side_items.each do |item|
152
+ view = QLabel.new(window)
153
+ view.set_geometry(12, item[:y], SIDEBAR_W - 24, 36)
154
+ view.set_alignment(Qt::AlignCenter)
155
+ view.set_text(item[:text])
156
+ view.set_style_sheet(item[:key] == :tracker ? SIDE_ACTIVE : SIDE_ITEM)
157
+ item[:view] = view
158
+ end
159
+
160
+ project_title = QLabel.new(window)
161
+ project_title.set_geometry(16, 286, SIDEBAR_W - 32, 26)
162
+ project_title.set_alignment(Qt::AlignCenter)
163
+ project_title.set_style_sheet(MUTED)
164
+ project_title.set_text('PROJECTS')
165
+
166
+ project_slots = Array.new(PROJECT_SLOT_COUNT) do |i|
167
+ y = 316 + (i * 34)
168
+ view = QPushButton.new(window)
169
+ view.set_geometry(12, y, SIDEBAR_W - 24, 30)
170
+ view.set_style_sheet(PROJ_ITEM)
171
+ view.set_text('')
172
+ { view: view, x: 12, y: y, w: SIDEBAR_W - 24, h: 30, project: nil }
173
+ end
174
+
175
+ topbar = QLabel.new(window)
176
+ topbar.set_geometry(CONTENT_X, 8, CONTENT_W, TOPBAR_H)
177
+ topbar.set_style_sheet(BG_TOPBAR)
178
+
179
+ title = QLabel.new(window)
180
+ title.set_geometry(CONTENT_X + 16, 16, 400, 36)
181
+ title.set_alignment(Qt::AlignCenter)
182
+ title.set_style_sheet(TITLE)
183
+ title.set_text('TIME TRACKER')
184
+
185
+ clock = QLabel.new(window)
186
+ clock.set_geometry(CONTENT_X + CONTENT_W - 240, 16, 220, 36)
187
+ clock.set_alignment(Qt::AlignCenter)
188
+ clock.set_style_sheet(MUTED)
189
+
190
+ quick_row = QLabel.new(window)
191
+ quick_row.set_geometry(CONTENT_X, TOPBAR_H + 22, CONTENT_W, 74)
192
+ quick_row.set_style_sheet(CARD)
193
+
194
+ task_input = QLineEdit.new(window)
195
+ task_input.set_geometry(CONTENT_X + 14, TOPBAR_H + 35, CONTENT_W - 470, 48)
196
+ task_input.set_style_sheet(INPUT_LIKE)
197
+ task_input.set_placeholder_text('What are you working on?')
198
+ task_input.text = 'gui-clockify'
199
+
200
+ project_pill = QLabel.new(window)
201
+ project_pill.set_geometry(CONTENT_X + CONTENT_W - 440, TOPBAR_H + 35, 150, 48)
202
+ project_pill.set_alignment(Qt::AlignCenter)
203
+ project_pill.set_style_sheet(BTN_GHOST)
204
+ project_pill.set_text('Project: ALL')
205
+
206
+ live_timer = QLabel.new(window)
207
+ live_timer.set_geometry(CONTENT_X + CONTENT_W - 280, TOPBAR_H + 35, 120, 48)
208
+ live_timer.set_alignment(Qt::AlignCenter)
209
+ live_timer.set_style_sheet(CARD)
210
+ live_timer.set_text('00:00:00')
211
+
212
+ start_btn = QPushButton.new(window)
213
+ start_btn.set_geometry(CONTENT_X + CONTENT_W - 152, TOPBAR_H + 35, 64, 48)
214
+ start_btn.set_style_sheet(BTN_PRIMARY)
215
+ start_btn.set_text('START')
216
+
217
+ stop_btn = QPushButton.new(window)
218
+ stop_btn.set_geometry(CONTENT_X + CONTENT_W - 80, TOPBAR_H + 35, 64, 48)
219
+ stop_btn.set_style_sheet(BTN_DANGER)
220
+ stop_btn.set_text('STOP')
221
+
222
+ summary = QLabel.new(window)
223
+ summary.set_geometry(CONTENT_X, TOPBAR_H + 108, CONTENT_W, 42)
224
+ summary.set_alignment(Qt::AlignCenter)
225
+ summary.set_style_sheet(CARD)
226
+ summary.set_text('Week total: 00:00:00 | Total: 00:00:00')
227
+
228
+ scroll = QScrollArea.new(window)
229
+ scroll.set_geometry(CONTENT_X, TOPBAR_H + 156, CONTENT_W, WINDOW_H - (TOPBAR_H + 170))
230
+ scroll.set_widget_resizable(0)
231
+ scroll.set_style_sheet(AREA_STYLE)
232
+
233
+ scroll_host = QWidget.new(window)
234
+ scroll_host.set_geometry(0, 0, CONTENT_W - 20, 1200)
235
+ scroll_host.set_style_sheet('background-color: #f3f6fa; border: 0px;')
236
+ scroll.set_widget(scroll_host)
237
+
238
+ refresh_btn = QPushButton.new(window)
239
+ refresh_btn.set_geometry(CONTENT_X + CONTENT_W - 124, TOPBAR_H + 112, 110, 34)
240
+ refresh_btn.set_style_sheet(BTN_GHOST)
241
+ refresh_btn.set_text('REFRESH')
242
+
243
+ expand_all_btn = QPushButton.new(window)
244
+ expand_all_btn.set_geometry(CONTENT_X + CONTENT_W - 364, TOPBAR_H + 112, 112, 34)
245
+ expand_all_btn.set_style_sheet(BTN_GHOST)
246
+ expand_all_btn.set_text('EXPAND ALL')
247
+
248
+ collapse_all_btn = QPushButton.new(window)
249
+ collapse_all_btn.set_geometry(CONTENT_X + CONTENT_W - 244, TOPBAR_H + 112, 112, 34)
250
+ collapse_all_btn.set_style_sheet(BTN_GHOST)
251
+ collapse_all_btn.set_text('COLLAPSE ALL')
252
+
253
+ run_t = lambda do |*args|
254
+ out, st = Open3.capture2e(TIMETRAP_BIN, *args)
255
+ [st.success?, out]
256
+ rescue Errno::ENOENT
257
+ [false, "Command not found: #{TIMETRAP_BIN}"]
258
+ rescue StandardError => e
259
+ [false, "#{e.class}: #{e.message}"]
260
+ end
261
+
262
+ dbg = lambda do |msg|
263
+ puts "[timetrap-ui] #{msg}" if DEBUG_UI
264
+ end
265
+
266
+ ptr = lambda do |obj|
267
+ h = obj&.handle
268
+ h ? format('0x%x', h.address) : 'nil'
269
+ rescue StandardError
270
+ 'err'
271
+ end
272
+
273
+ geo = lambda do |x, y, w, h|
274
+ "x=#{x} y=#{y} w=#{w} h=#{h}"
275
+ end
276
+
277
+ parse_time_or_nil = lambda do |value|
278
+ Time.parse(value)
279
+ rescue ArgumentError, TypeError
280
+ nil
281
+ end
282
+
283
+ fetch_entries = lambda do
284
+ if TIMETRAP_API
285
+ Timetrap::Entry.order(:start).all.map do |e|
286
+ {
287
+ id: e.id,
288
+ note: e.note.to_s,
289
+ sheet: e.sheet.to_s,
290
+ start_time: e[:start],
291
+ end_time: e[:end]
292
+ }
293
+ end
294
+ else
295
+ ok, out = run_t.call('display', '--format', 'json')
296
+ return [] unless ok
297
+
298
+ begin
299
+ parsed = JSON.parse(out)
300
+ next [] unless parsed.is_a?(Array)
301
+
302
+ parsed.map do |e|
303
+ {
304
+ id: e['id'],
305
+ note: e['note'].to_s,
306
+ sheet: e['sheet'].to_s,
307
+ start_time: parse_time_or_nil.call(e['start']),
308
+ end_time: parse_time_or_nil.call(e['end'])
309
+ }
310
+ end
311
+ rescue JSON::ParserError
312
+ []
313
+ end
314
+ end
315
+ end
316
+
317
+ fetch_active = lambda do
318
+ if TIMETRAP_API
319
+ active = Timetrap::Timer.active_entry
320
+ [active, active ? active[:start] : nil]
321
+ else
322
+ ok, out = run_t.call('now')
323
+ if ok && out =~ /(\d{4}-\d{2}-\d{2} [0-9:]+ [+-]\d{4})/
324
+ [true, parse_time_or_nil.call(Regexp.last_match(1))]
325
+ else
326
+ [nil, nil]
327
+ end
328
+ end
329
+ end
330
+
331
+ seconds_to_hms = lambda do |seconds|
332
+ seconds = [seconds.to_i, 0].max
333
+ h = seconds / 3600
334
+ m = (seconds % 3600) / 60
335
+ s = seconds % 60
336
+ format('%<hours>02d:%<minutes>02d:%<seconds>02d', hours: h, minutes: m, seconds: s)
337
+ end
338
+
339
+ entry_seconds = lambda do |entry|
340
+ st = entry[:start_time]
341
+ en = entry[:end_time] || Time.now
342
+ return 0 unless st
343
+
344
+ [en.to_i - st.to_i, 0].max
345
+ end
346
+
347
+ split_sheet = lambda do |sheet|
348
+ raw = sheet.to_s
349
+ return ['(default)', '(default task)'] if raw.strip.empty?
350
+
351
+ parts = raw.split('|', 2)
352
+ if parts.length == 2
353
+ project = parts[0].strip
354
+ task = parts[1].strip
355
+ project = '(default)' if project.empty?
356
+ task = '(default task)' if task.empty?
357
+ [project, task]
358
+ else
359
+ [raw.strip, '(default task)']
360
+ end
361
+ end
362
+
363
+ week_start_for = lambda do |entry|
364
+ dt = entry[:start_time]&.to_date || Date.today
365
+ dt - ((dt.wday + 6) % 7)
366
+ end
367
+
368
+ fmt_range = lambda do |entry|
369
+ s = entry[:start_time] ? entry[:start_time].strftime('%H:%M') : '--:--'
370
+ e = entry[:end_time] ? entry[:end_time].strftime('%H:%M') : 'running'
371
+ "#{s} - #{e}"
372
+ end
373
+
374
+ current_started_at = nil
375
+ selected_project = '* ALL'
376
+ entries_cache = []
377
+ expanded_rows = {}
378
+ expanded_weeks = {}
379
+ render_widgets = []
380
+ pending_render = false
381
+ pending_refresh = false
382
+ last_week_keys = []
383
+ last_project_keys = []
384
+
385
+ refresh_project_sidebar = lambda do
386
+ projects = entries_cache.map { |e| split_sheet.call(e[:sheet]).first }.uniq.sort
387
+ values = ['* ALL', *projects].first(PROJECT_SLOT_COUNT)
388
+ dbg.call("sidebar projects=#{projects.length} selected=#{selected_project.inspect}")
389
+
390
+ project_slots.each_with_index do |slot, i|
391
+ project = values[i]
392
+ slot[:project] = project
393
+
394
+ if project
395
+ slot[:view].set_text(project[0, 24])
396
+ slot[:view].set_style_sheet(project == selected_project ? PROJ_ACTIVE : PROJ_ITEM)
397
+ else
398
+ slot[:view].set_text('')
399
+ slot[:view].set_style_sheet(PROJ_ITEM)
400
+ end
401
+ end
402
+ end
403
+
404
+ add_row_label = lambda do |x, y, w, h, style, text, center: false|
405
+ label = QLabel.new(scroll_host)
406
+ label.set_geometry(x, y, w, h)
407
+ label.set_style_sheet(style)
408
+ label.set_text(text)
409
+ label.set_alignment(Qt::AlignCenter) if center
410
+ label.show
411
+ render_widgets << label
412
+ label
413
+ end
414
+
415
+ render_data = TimetrapRenderData.new(split_sheet: split_sheet, week_start_for: week_start_for)
416
+
417
+ render_begin_log_text = lambda do |selected, all_count, filtered_count|
418
+ "render begin selected=#{selected.inspect} entries_cache=#{all_count} filtered=#{filtered_count}"
419
+ end
420
+
421
+ week_title_text = lambda do |week_marker, wk, week_sec|
422
+ "#{week_marker} #{wk.strftime('%b %-d')} - #{(wk + 6).strftime('%b %-d')} " \
423
+ "Week total: #{seconds_to_hms.call(week_sec)}"
424
+ end
425
+
426
+ project_row_text = lambda do |marker, group, p_total|
427
+ "#{marker} #{group[:project]} | #{group[:task]} " \
428
+ "(#{group[:entries].length} entries) #{seconds_to_hms.call(p_total)}"
429
+ end
430
+
431
+ summary_text = lambda do |selected, filtered_count, total_sec|
432
+ "Selected: #{selected} | Entries: #{filtered_count} | Total: #{seconds_to_hms.call(total_sec)}"
433
+ end
434
+
435
+ render_done_log_text = lambda do |week_count, day_count, project_group_count, content_h,
436
+ total_widgets, lbl_count, btn_count, row_count|
437
+ "render done weeks=#{week_count} days=#{day_count} groups=#{project_group_count} " \
438
+ "content_h=#{content_h} widgets_total=#{total_widgets} labels=#{lbl_count} " \
439
+ "buttons=#{btn_count} click_rows=#{row_count}"
440
+ end
441
+
442
+ render_geometry_log_text = lambda do |host_w, content_h|
443
+ "render geometry scroll=#{geo.call(CONTENT_X, TOPBAR_H + 156, CONTENT_W, WINDOW_H - (TOPBAR_H + 170))} " \
444
+ "host=#{geo.call(0, 0, host_w, content_h)}"
445
+ end
446
+
447
+ render_empty_state = lambda do |y, selected|
448
+ add_row_label.call(18, y + 8, CONTENT_W - 62, 44, DAY_STYLE, "No entries for filter: #{selected}")
449
+ dbg.call("render empty for selected=#{selected.inspect}")
450
+ y + 56
451
+ end
452
+
453
+ reset_render_widgets = lambda do
454
+ render_widgets.each(&:hide)
455
+ render_widgets.clear
456
+ end
457
+
458
+ render_entry_details = lambda do |group, y|
459
+ group[:entries].sort_by { |e| e[:start_time] || Time.now }.reverse.each do |entry|
460
+ note = entry[:note].to_s.strip
461
+ note = '(no note)' if note.empty?
462
+ detail = "#{fmt_range.call(entry)} #{seconds_to_hms.call(entry_seconds.call(entry))} #{note[0, 80]}"
463
+ add_row_label.call(26, y, CONTENT_W - 78, 34, DETAIL_ROW, detail)
464
+ y += 36
465
+ end
466
+ y
467
+ end
468
+
469
+ render_project_group_row = lambda do |day, group, y, project_keys_this_render|
470
+ p_total = group[:entries].sum { |e| entry_seconds.call(e) }
471
+ exp_key = "#{day}|#{group[:project]}|#{group[:task]}"
472
+ project_keys_this_render << exp_key
473
+ expanded = expanded_rows[exp_key]
474
+ marker = expanded ? '▼' : '▶'
475
+ click_key = exp_key
476
+ row = QLabel.new(scroll_host)
477
+ row.set_geometry(18, y, CONTENT_W - 62, 40)
478
+ row.set_style_sheet(PROJECT_ROW)
479
+ row.set_text(project_row_text.call(marker, group, p_total))
480
+ row.show
481
+ render_widgets << row
482
+ row.on(:mouse_button_release) do |_ev|
483
+ dbg.call("click project-row #{click_key}")
484
+ expanded_rows[click_key] = !expanded_rows[click_key]
485
+ dbg.call("click project-row toggled #{click_key}=#{expanded_rows[click_key]}")
486
+ pending_render = true
487
+ end
488
+ y += 42
489
+ [y, expanded]
490
+ end
491
+
492
+ render_day_section = lambda do |day, day_entries, y, project_keys_this_render|
493
+ day_sec = day_entries.sum { |e| entry_seconds.call(e) }
494
+ add_row_label.call(
495
+ 14, y, CONTENT_W - 54, 38, DAY_STYLE,
496
+ "#{day.strftime('%a, %b %-d')} Total: #{seconds_to_hms.call(day_sec)}"
497
+ )
498
+ y += 42
499
+ groups_count = 0
500
+ render_data.project_groups(day_entries).each do |group|
501
+ groups_count += 1
502
+ y, expanded = render_project_group_row.call(day, group, y, project_keys_this_render)
503
+ y = render_entry_details.call(group, y) if expanded
504
+ end
505
+ [y + 10, groups_count]
506
+ end
507
+
508
+ render_week_day_sections = lambda do |week_entries, y, project_keys_this_render|
509
+ day_count_add = 0
510
+ project_group_count_add = 0
511
+ by_day = render_data.group_by_day(week_entries)
512
+ by_day.keys.sort.reverse.each do |day|
513
+ day_count_add += 1
514
+ y, groups_count = render_day_section.call(day, by_day[day], y, project_keys_this_render)
515
+ project_group_count_add += groups_count
516
+ end
517
+ [y + 8, day_count_add, project_group_count_add]
518
+ end
519
+
520
+ render_week_header = lambda do |wk, week_entries, y, week_keys_this_render|
521
+ week_sec = week_entries.sum { |e| entry_seconds.call(e) }
522
+ week_key = wk.iso8601
523
+ week_keys_this_render << week_key
524
+ week_expanded = expanded_weeks.fetch(week_key, true)
525
+ week_marker = week_expanded ? '▼' : '▶'
526
+ week_label = QLabel.new(scroll_host)
527
+ week_label.set_geometry(10, y, CONTENT_W - 22, 44)
528
+ week_label.set_style_sheet(WEEK_STYLE)
529
+ week_label.set_text(week_title_text.call(week_marker, wk, week_sec))
530
+ week_label.show
531
+ render_widgets << week_label
532
+ week_click_key = week_key
533
+ week_label.on(:mouse_button_release) do |_ev|
534
+ expanded_weeks[week_click_key] = !expanded_weeks.fetch(week_click_key, true)
535
+ dbg.call("click week-toggle #{week_click_key} expanded=#{expanded_weeks[week_click_key]}")
536
+ pending_render = true
537
+ end
538
+ [y + 52, week_sec, week_expanded]
539
+ end
540
+
541
+ build_render_state = lambda do
542
+ {
543
+ y: 10, total_sec: 0, week_count: 0, day_count: 0, project_group_count: 0,
544
+ week_keys: [], project_keys: []
545
+ }
546
+ end
547
+
548
+ render_weeks = lambda do |by_week, state|
549
+ by_week.keys.sort.reverse.each do |wk|
550
+ state[:week_count] += 1
551
+ week_entries = by_week[wk]
552
+ state[:y], week_sec, week_expanded = render_week_header.call(wk, week_entries, state[:y], state[:week_keys])
553
+ state[:total_sec] += week_sec
554
+ next unless week_expanded
555
+
556
+ state[:y], day_count_add, project_group_count_add = render_week_day_sections.call(
557
+ week_entries, state[:y], state[:project_keys]
558
+ )
559
+ state[:day_count] += day_count_add
560
+ state[:project_group_count] += project_group_count_add
561
+ end
562
+ end
563
+
564
+ finalize_render = lambda do |state, filtered_count|
565
+ summary.set_text(summary_text.call(selected_project, filtered_count, state[:total_sec]))
566
+ project_pill.set_text("Project: #{selected_project[0, 20]}")
567
+ last_week_keys = state[:week_keys]
568
+ last_project_keys = state[:project_keys]
569
+ content_h = [state[:y] + 30, 900].max
570
+ host_w = CONTENT_W - 20
571
+ scroll_host.set_geometry(0, 0, host_w, content_h)
572
+ btn_count = render_widgets.count { |w| w.is_a?(QPushButton) }
573
+ row_count = render_widgets.count { |w| w.is_a?(QLabel) && w.respond_to?(:on) }
574
+ lbl_count = render_widgets.count { |w| w.is_a?(QLabel) }
575
+ dbg.call(
576
+ render_done_log_text.call(
577
+ state[:week_count], state[:day_count], state[:project_group_count], content_h,
578
+ render_widgets.length, lbl_count, btn_count, row_count
579
+ )
580
+ )
581
+ dbg.call(render_geometry_log_text.call(host_w, content_h))
582
+ end
583
+
584
+ debug_render_sample = lambda do
585
+ return unless DEBUG_UI
586
+
587
+ sample = render_widgets.first(3).map do |w|
588
+ t = if w.respond_to?(:text)
589
+ w.text.to_s
590
+ elsif w.respond_to?(:window_title)
591
+ w.window_title.to_s
592
+ else
593
+ ''
594
+ end
595
+ "#{w.class}@#{ptr.call(w)}:#{t[0, 48].inspect}"
596
+ end
597
+ dbg.call("render sample #{sample.join(' | ')} | visible window=#{window.is_visible} host=#{scroll_host.is_visible}")
598
+ end
599
+
600
+ render_blocks = lambda do
601
+ reset_render_widgets.call
602
+ filtered, by_week = render_data.filtered_week_groups(entries_cache, selected_project)
603
+ dbg.call(render_begin_log_text.call(selected_project, entries_cache.length, filtered.length))
604
+ state = build_render_state.call
605
+ render_weeks.call(by_week, state)
606
+ state[:y] = render_empty_state.call(state[:y], selected_project) if filtered.empty?
607
+ finalize_render.call(state, filtered.length)
608
+ debug_render_sample.call
609
+ end
610
+
611
+ refresh_data = lambda do
612
+ active, started_at = fetch_active.call
613
+ current_started_at = started_at
614
+
615
+ entries_cache = fetch_entries.call
616
+ dbg.call("refresh_data fetched entries=#{entries_cache.length} api=#{TIMETRAP_API}")
617
+ projects = entries_cache.map { |e| split_sheet.call(e[:sheet]).first }.uniq
618
+ selected_project = '* ALL' if selected_project != '* ALL' && !projects.include?(selected_project)
619
+
620
+ if active.respond_to?(:note)
621
+ txt = active.note.to_s.strip
622
+ task_input.text = txt.empty? ? 'gui-clockify' : txt[0, 100]
623
+ elsif task_input.text.to_s.strip.empty?
624
+ task_input.text = 'gui-clockify'
625
+ end
626
+
627
+ expanded_weeks.clear
628
+ expanded_rows.clear
629
+
630
+ refresh_project_sidebar.call
631
+ render_blocks.call
632
+ end
633
+
634
+ flash = lambda do |label|
635
+ label.set_style_sheet(BTN_ACTIVE)
636
+ QApplication.process_events
637
+ sleep(0.04)
638
+
639
+ if label == start_btn
640
+ label.set_style_sheet(BTN_PRIMARY)
641
+ elsif label == stop_btn
642
+ label.set_style_sheet(BTN_DANGER)
643
+ else
644
+ label.set_style_sheet(BTN_GHOST)
645
+ end
646
+ end
647
+
648
+ start_tracking = lambda do
649
+ note = task_input.text.to_s.strip
650
+ note = 'gui-clockify' if note.empty?
651
+ if TIMETRAP_API
652
+ Timetrap::Timer.start(note)
653
+ else
654
+ run_t.call('in', note)
655
+ end
656
+ end
657
+
658
+ stop_tracking = lambda do
659
+ if TIMETRAP_API
660
+ active = Timetrap::Timer.active_entry
661
+ Timetrap::Timer.stop(active) if active
662
+ else
663
+ run_t.call('out')
664
+ end
665
+ end
666
+
667
+ handle_action = lambda do |key|
668
+ case key
669
+ when :start
670
+ start_tracking.call
671
+ current_started_at ||= Time.now
672
+ when :stop
673
+ stop_tracking.call
674
+ current_started_at = nil
675
+ when :refresh
676
+ pending_refresh = true
677
+ end
678
+ rescue StandardError
679
+ pending_render = true
680
+ end
681
+
682
+ start_btn.connect('clicked') do |_checked|
683
+ dbg.call('click START')
684
+ flash.call(start_btn)
685
+ handle_action.call(:start)
686
+ end
687
+
688
+ stop_btn.connect('clicked') do |_checked|
689
+ dbg.call('click STOP')
690
+ flash.call(stop_btn)
691
+ handle_action.call(:stop)
692
+ end
693
+
694
+ refresh_btn.connect('clicked') do |_checked|
695
+ dbg.call('click REFRESH')
696
+ flash.call(refresh_btn)
697
+ pending_refresh = true
698
+ handle_action.call(:refresh)
699
+ end
700
+
701
+ expand_all_btn.connect('clicked') do |_checked|
702
+ dbg.call('click EXPAND ALL')
703
+ flash.call(expand_all_btn)
704
+ last_week_keys.each { |wk| expanded_weeks[wk] = true }
705
+ last_project_keys.each { |pk| expanded_rows[pk] = true }
706
+ pending_render = true
707
+ end
708
+
709
+ collapse_all_btn.connect('clicked') do |_checked|
710
+ dbg.call('click COLLAPSE ALL')
711
+ flash.call(collapse_all_btn)
712
+ last_week_keys.each { |wk| expanded_weeks[wk] = false }
713
+ last_project_keys.each { |pk| expanded_rows[pk] = false }
714
+ pending_render = true
715
+ end
716
+
717
+ project_slots.each do |slot|
718
+ this_slot = slot
719
+ this_slot[:view].connect('clicked') do |_checked|
720
+ next unless this_slot[:project]
721
+
722
+ dbg.call("click project #{this_slot[:project].inspect}")
723
+ selected_project = this_slot[:project]
724
+ refresh_project_sidebar.call
725
+ dbg.call("click project selected=#{selected_project.inspect} slot_y=#{this_slot[:y]} slot_h=#{this_slot[:h]}")
726
+ pending_render = true
727
+ end
728
+ end
729
+
730
+ refresh_data.call
731
+ window.show
732
+ heartbeat = QTimer.new(window)
733
+ heartbeat.set_interval(50)
734
+ heartbeat.connect('timeout') do |_payload|
735
+ next if window.is_visible.zero?
736
+
737
+ now = Time.now
738
+ clock.set_text(now.strftime('%a %d %b %Y %H:%M:%S'))
739
+
740
+ if current_started_at
741
+ live_timer.set_text(seconds_to_hms.call(now - current_started_at))
742
+ else
743
+ live_timer.set_text('00:00:00')
744
+ end
745
+
746
+ if pending_refresh
747
+ refresh_data.call
748
+ pending_refresh = false
749
+ pending_render = false
750
+ elsif pending_render
751
+ render_blocks.call
752
+ pending_render = false
753
+ end
754
+ end
755
+ heartbeat.start
756
+
757
+ app.exec
758
+
759
+ app.dispose