qtimetrap 0.1.1

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 (130) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +25 -0
  3. data/README.md +92 -0
  4. data/Rakefile +105 -0
  5. data/app/assets/icons/qtimetrap-icon-128.png +0 -0
  6. data/app/assets/icons/qtimetrap-icon-256.png +0 -0
  7. data/app/assets/icons/qtimetrap-icon-512.png +0 -0
  8. data/app/assets/icons/qtimetrap-icon.svg +123 -0
  9. data/app/components/entries/branch_hierarchy_helpers.rb +41 -0
  10. data/app/components/entries/leaf_archive_helpers.rb +23 -0
  11. data/app/components/entries/leaf_note_helpers.rb +118 -0
  12. data/app/components/entries/leaf_task_helpers.rb +81 -0
  13. data/app/components/entries/leaf_time_helpers.rb +105 -0
  14. data/app/components/entries/list_component.rb +118 -0
  15. data/app/components/entries/list_host_helpers.rb +27 -0
  16. data/app/components/entries/list_state_helpers.rb +59 -0
  17. data/app/components/entries/node_presentation_helpers.rb +29 -0
  18. data/app/components/entries/render_helpers.rb +25 -0
  19. data/app/components/entries/tree_helpers.rb +133 -0
  20. data/app/components/entries/tree_toolbar_helpers.rb +98 -0
  21. data/app/components/project_sidebar/archive_toggle_helpers.rb +22 -0
  22. data/app/components/project_sidebar/component.rb +125 -0
  23. data/app/components/project_sidebar/logo_helpers.rb +53 -0
  24. data/app/components/project_sidebar/project_button_helpers.rb +72 -0
  25. data/app/components/project_sidebar/project_selection_helpers.rb +94 -0
  26. data/app/components/project_sidebar/task_helpers.rb +94 -0
  27. data/app/components/project_sidebar/task_selection_helpers.rb +72 -0
  28. data/app/components/qt_ui_helpers.rb +27 -0
  29. data/app/components/tracker_controls/component.rb +80 -0
  30. data/app/components/tracker_controls/layout_builder.rb +100 -0
  31. data/app/components/tracker_controls/layout_helpers.rb +88 -0
  32. data/app/models/null_settings_store.rb +12 -0
  33. data/app/models/time_entry.rb +59 -0
  34. data/app/services/archived_entries_store.rb +71 -0
  35. data/app/services/formatters.rb +29 -0
  36. data/app/services/settings_store.rb +83 -0
  37. data/app/services/timetrap_gateway.rb +89 -0
  38. data/app/services/timetrap_gateway_logger.rb +54 -0
  39. data/app/services/timetrap_gateway_query_helpers.rb +102 -0
  40. data/app/services/timetrap_gateway_start_helpers.rb +28 -0
  41. data/app/services/timetrap_gateway_update_note_helpers.rb +48 -0
  42. data/app/services/timetrap_gateway_update_task_helpers.rb +47 -0
  43. data/app/services/timetrap_gateway_update_time_helpers.rb +50 -0
  44. data/app/styles/theme.rb +61 -0
  45. data/app/styles/themes/dark/application.qss +8 -0
  46. data/app/styles/themes/dark/entries_list.qss +235 -0
  47. data/app/styles/themes/dark/project_sidebar.qss +99 -0
  48. data/app/styles/themes/dark/snippets/app_background.qss +2 -0
  49. data/app/styles/themes/dark/snippets/button_ghost.qss +5 -0
  50. data/app/styles/themes/dark/snippets/button_start.qss +5 -0
  51. data/app/styles/themes/dark/snippets/button_stop.qss +5 -0
  52. data/app/styles/themes/dark/snippets/entries_host.qss +2 -0
  53. data/app/styles/themes/dark/snippets/entries_scroll.qss +16 -0
  54. data/app/styles/themes/dark/snippets/entry_row_day.qss +6 -0
  55. data/app/styles/themes/dark/snippets/entry_row_detail.qss +5 -0
  56. data/app/styles/themes/dark/snippets/entry_row_project.qss +6 -0
  57. data/app/styles/themes/dark/snippets/project_button.qss +5 -0
  58. data/app/styles/themes/dark/snippets/project_button_active.qss +5 -0
  59. data/app/styles/themes/dark/snippets/project_sidebar_heading.qss +2 -0
  60. data/app/styles/themes/dark/snippets/project_sidebar_logo.qss +3 -0
  61. data/app/styles/themes/dark/snippets/project_sidebar_panel.qss +2 -0
  62. data/app/styles/themes/dark/snippets/tracker_clock.qss +2 -0
  63. data/app/styles/themes/dark/snippets/tracker_input.qss +5 -0
  64. data/app/styles/themes/dark/snippets/tracker_project_label.qss +5 -0
  65. data/app/styles/themes/dark/snippets/tracker_row.qss +3 -0
  66. data/app/styles/themes/dark/snippets/tracker_summary.qss +3 -0
  67. data/app/styles/themes/dark/snippets/tracker_timer.qss +3 -0
  68. data/app/styles/themes/dark/snippets/tracker_title.qss +3 -0
  69. data/app/styles/themes/dark/snippets/tracker_topbar.qss +2 -0
  70. data/app/styles/themes/dark/tracker_controls.qss +84 -0
  71. data/app/styles/themes/light/application.qss +8 -0
  72. data/app/styles/themes/light/entries_list.qss +235 -0
  73. data/app/styles/themes/light/project_sidebar.qss +99 -0
  74. data/app/styles/themes/light/snippets/app_background.qss +2 -0
  75. data/app/styles/themes/light/snippets/button_ghost.qss +5 -0
  76. data/app/styles/themes/light/snippets/button_start.qss +5 -0
  77. data/app/styles/themes/light/snippets/button_stop.qss +5 -0
  78. data/app/styles/themes/light/snippets/entries_host.qss +2 -0
  79. data/app/styles/themes/light/snippets/entries_scroll.qss +16 -0
  80. data/app/styles/themes/light/snippets/entry_row_day.qss +6 -0
  81. data/app/styles/themes/light/snippets/entry_row_detail.qss +5 -0
  82. data/app/styles/themes/light/snippets/entry_row_project.qss +6 -0
  83. data/app/styles/themes/light/snippets/project_button.qss +5 -0
  84. data/app/styles/themes/light/snippets/project_button_active.qss +5 -0
  85. data/app/styles/themes/light/snippets/project_sidebar_heading.qss +2 -0
  86. data/app/styles/themes/light/snippets/project_sidebar_logo.qss +3 -0
  87. data/app/styles/themes/light/snippets/project_sidebar_panel.qss +2 -0
  88. data/app/styles/themes/light/snippets/tracker_clock.qss +2 -0
  89. data/app/styles/themes/light/snippets/tracker_input.qss +5 -0
  90. data/app/styles/themes/light/snippets/tracker_project_label.qss +5 -0
  91. data/app/styles/themes/light/snippets/tracker_row.qss +3 -0
  92. data/app/styles/themes/light/snippets/tracker_summary.qss +3 -0
  93. data/app/styles/themes/light/snippets/tracker_timer.qss +3 -0
  94. data/app/styles/themes/light/snippets/tracker_title.qss +3 -0
  95. data/app/styles/themes/light/snippets/tracker_topbar.qss +2 -0
  96. data/app/styles/themes/light/tracker_controls.qss +84 -0
  97. data/app/view_models/entry_nodes_builder.rb +109 -0
  98. data/app/view_models/main_view_model.rb +135 -0
  99. data/app/view_models/main_view_model_archive_mode_helpers.rb +102 -0
  100. data/app/view_models/main_view_model_entry_note_helpers.rb +30 -0
  101. data/app/view_models/main_view_model_entry_task_helpers.rb +39 -0
  102. data/app/view_models/main_view_model_entry_time_helpers.rb +45 -0
  103. data/app/view_models/main_view_model_sheet_helpers.rb +74 -0
  104. data/app/view_models/main_view_model_task_filter_helpers.rb +58 -0
  105. data/app/view_models/main_view_model_time_range_filter_helpers.rb +23 -0
  106. data/app/views/main_window.rb +126 -0
  107. data/app/views/main_window_layout_builder.rb +114 -0
  108. data/app/views/main_window_runtime.rb +123 -0
  109. data/app/views/main_window_runtime_archive_helpers.rb +26 -0
  110. data/app/views/main_window_runtime_entry_task_helpers.rb +21 -0
  111. data/app/views/main_window_runtime_entry_time_helpers.rb +17 -0
  112. data/app/views/main_window_runtime_key_helpers.rb +55 -0
  113. data/app/views/main_window_runtime_render_helpers.rb +50 -0
  114. data/app/views/main_window_splitter_toggle_bootstrap_helpers.rb +34 -0
  115. data/app/views/main_window_splitter_toggle_helpers.rb +103 -0
  116. data/app/views/main_window_splitter_toggle_hover_helpers.rb +71 -0
  117. data/app/views/main_window_splitter_toggle_layout_helpers.rb +24 -0
  118. data/app/views/main_window_ui_helpers.rb +45 -0
  119. data/app/views/window_icon_loader.rb +37 -0
  120. data/bin/qtimetrap +7 -0
  121. data/config/application.rb +87 -0
  122. data/config/environments/development.rb +5 -0
  123. data/config/environments/production.rb +5 -0
  124. data/config/initializers/theme.rb +5 -0
  125. data/config/initializers/timetrap.rb +5 -0
  126. data/lib/qtimetrap/configuration.rb +15 -0
  127. data/lib/qtimetrap/container.rb +57 -0
  128. data/lib/qtimetrap/version.rb +5 -0
  129. data/lib/qtimetrap.rb +25 -0
  130. metadata +207 -0
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QTimetrap
4
+ module Entries
5
+ # Entry-leaf helpers for editable start/end time controls.
6
+ module LeafTimeHelpers
7
+ private
8
+
9
+ def add_entry_time_widgets(row_layout, row, node)
10
+ start_input = build_entry_time_input(
11
+ row,
12
+ object_name: 'entry_node_entry_start',
13
+ value: node.fetch(:start_label, '--:--'),
14
+ placeholder: '--:--'
15
+ )
16
+ end_input = build_entry_time_input(
17
+ row,
18
+ object_name: 'entry_node_entry_end',
19
+ value: node.fetch(:end_label, 'running'),
20
+ placeholder: 'running'
21
+ )
22
+ row_layout.add_widget(build_entry_time_group(row, start_input, end_input))
23
+ [start_input, end_input]
24
+ end
25
+
26
+ def build_entry_time_group(row, start_input, end_input)
27
+ QWidget.new(row).tap do |time_group|
28
+ time_group.set_object_name('entry_node_entry_time_group')
29
+ QHBoxLayout.new(time_group).tap do |time_layout|
30
+ time_layout.set_contents_margins(0, 0, 0, 0)
31
+ time_layout.set_spacing(4)
32
+ time_layout.add_widget(start_input)
33
+ time_layout.add_widget(build_entry_time_separator(time_group))
34
+ time_layout.add_widget(end_input)
35
+ end
36
+ end
37
+ end
38
+
39
+ def build_entry_time_separator(row)
40
+ QLabel.new(row).tap do |separator|
41
+ separator.set_object_name('entry_node_entry_time_sep')
42
+ separator.set_text('-')
43
+ end
44
+ end
45
+
46
+ def build_entry_time_input(row, object_name:, value:, placeholder:)
47
+ QLineEdit.new(row).tap do |time_input|
48
+ time_input.set_object_name(object_name)
49
+ time_input.text = value.to_s
50
+ time_input.set_placeholder_text(placeholder)
51
+ time_input.set_alignment(Qt::AlignCenter)
52
+ time_input.set_read_only(true)
53
+ time_input.set_fixed_width(58)
54
+ end
55
+ end
56
+
57
+ def bind_entry_time_input_events(start_input, end_input, entry_id)
58
+ bind_single_time_input(start_input, entry_id, start_input, end_input)
59
+ bind_single_time_input(end_input, entry_id, start_input, end_input)
60
+ end
61
+
62
+ def bind_single_time_input(time_input, entry_id, start_input, end_input)
63
+ time_input.on(:mouse_button_press) { |_| activate_entry_note_input(time_input) }
64
+ time_input.on(:key_press) do |event|
65
+ handle_entry_time_key_press(
66
+ time_input: time_input,
67
+ entry_id: entry_id,
68
+ start_input: start_input,
69
+ end_input: end_input,
70
+ event: event
71
+ )
72
+ end
73
+ time_input.connect('returnPressed') do |_|
74
+ handle_entry_time_commit(
75
+ time_input: time_input,
76
+ entry_id: entry_id,
77
+ start_input: start_input,
78
+ end_input: end_input
79
+ )
80
+ end
81
+ time_input.on(:focus_out) { |_| handle_entry_note_focus_out(time_input) }
82
+ end
83
+
84
+ def handle_entry_time_key_press(time_input:, entry_id:, start_input:, end_input:, event:)
85
+ return unless enter_key?(event)
86
+
87
+ handle_entry_time_commit(
88
+ time_input: time_input,
89
+ entry_id: entry_id,
90
+ start_input: start_input,
91
+ end_input: end_input
92
+ )
93
+ end
94
+
95
+ def handle_entry_time_commit(time_input:, entry_id:, start_input:, end_input:)
96
+ return if time_input.is_read_only
97
+
98
+ deactivate_entry_note_input(time_input)
99
+ return unless on_entry_time_change
100
+
101
+ on_entry_time_change.call(entry_id, start_input.text.to_s, end_input.text.to_s)
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QTimetrap
4
+ module Entries
5
+ # Renders expandable week/day/project nodes and leaf time entries.
6
+ class ListComponent
7
+ include ListHostHelpers
8
+ include ListStateHelpers
9
+ include QtUiHelpers
10
+ include TreeHelpers
11
+ include RenderHelpers
12
+
13
+ HOST_HORIZONTAL_MARGINS = 28
14
+ WIDTH_PADDING = 24
15
+ TIME_FILTER_DEBOUNCE_MS = 220
16
+
17
+ attr_reader :widget
18
+
19
+ def initialize(parent:, callbacks: {}, task_suggestions_for_project: nil)
20
+ @parent = parent
21
+ @on_entry_note_change = callbacks[:on_entry_note_change]
22
+ @on_entry_task_change = callbacks[:on_entry_task_change]
23
+ @task_suggestions_for_project = task_suggestions_for_project
24
+ @on_entry_time_change = callbacks[:on_entry_time_change]
25
+ @on_entry_archive = callbacks[:on_entry_archive]
26
+ @on_time_range_change = callbacks[:on_time_range_change]
27
+ initialize_state!
28
+ build
29
+ end
30
+
31
+ def render(nodes)
32
+ return if rendering
33
+
34
+ @rendering = true
35
+ @current_nodes = Array(nodes)
36
+ @branch_bindings = {}
37
+ @leaf_labels = []
38
+ @entry_rows = []
39
+ with_widget_updates_suspended { render_contents }
40
+ ensure
41
+ @rendering = false
42
+ end
43
+
44
+ def update_time_range_inputs(from_at:, to_at:)
45
+ @syncing_time_filters = true
46
+ set_time_filter_state(
47
+ toggle: time_filter_from_toggle,
48
+ input: time_filter_from_input,
49
+ value: from_at
50
+ )
51
+ set_time_filter_state(
52
+ toggle: time_filter_to_toggle,
53
+ input: time_filter_to_input,
54
+ value: to_at
55
+ )
56
+ ensure
57
+ @syncing_time_filters = false
58
+ end
59
+
60
+ def shutdown
61
+ return unless time_filter_debounce
62
+
63
+ time_filter_debounce.stop if time_filter_debounce.is_active
64
+ end
65
+
66
+ private
67
+
68
+ attr_reader :parent, :host, :host_layout, :expanded, :current_nodes, :branch_bindings, :leaf_labels, :entry_rows,
69
+ :rendering, :scroll_area, :on_entry_note_change, :on_entry_task_change, :task_suggestions_for_project,
70
+ :on_entry_time_change, :on_entry_archive, :on_time_range_change,
71
+ :time_filter_from_toggle, :time_filter_to_toggle, :time_filter_from_input, :time_filter_to_input,
72
+ :time_filter_debounce
73
+
74
+ def build
75
+ @widget = QWidget.new(parent)
76
+ widget.set_object_name('entries_panel')
77
+ panel_layout = build_panel_layout
78
+ @time_filter_debounce = build_time_filter_debounce_timer
79
+ panel_layout.add_widget(build_toolbar(parent_widget: widget))
80
+ @scroll_area = build_scroll_area
81
+ bind_scroll_resize
82
+ panel_layout.add_widget(scroll_area)
83
+ panel_layout.set_stretch(1, 1)
84
+ rebuild_host!
85
+ end
86
+
87
+ def branch_button_width
88
+ available = scroll_area.width - HOST_HORIZONTAL_MARGINS - WIDTH_PADDING
89
+ [available, 120].max
90
+ end
91
+
92
+ def adjust_node_widths
93
+ width = branch_button_width
94
+ branch_bindings.each_value { |binding| binding.fetch(:button).set_fixed_width(width) }
95
+ leaf_labels.each { |label| label.set_fixed_width(width) }
96
+ entry_rows.each { |row| row.set_fixed_width(width) }
97
+ end
98
+
99
+ def build_panel_layout
100
+ QVBoxLayout.new(widget).tap do |layout|
101
+ layout.set_contents_margins(0, 0, 0, 0)
102
+ layout.set_spacing(6)
103
+ end
104
+ end
105
+
106
+ def build_scroll_area
107
+ QScrollArea.new(widget).tap do |area|
108
+ area.set_object_name('entries_scroll')
109
+ area.set_widget_resizable(true)
110
+ end
111
+ end
112
+
113
+ def bind_scroll_resize
114
+ scroll_area.on(:resize) { |_| adjust_node_widths }
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QTimetrap
4
+ module Entries
5
+ # Host/viewport helpers for entries list rendering.
6
+ module ListHostHelpers
7
+ private
8
+
9
+ def build_host
10
+ QWidget.new(scroll_area).tap { |container| container.set_object_name('entries_host') }
11
+ end
12
+
13
+ def build_host_layout
14
+ QVBoxLayout.new(host).tap do |layout|
15
+ layout.set_contents_margins(14, 10, 14, 10)
16
+ layout.set_spacing(2)
17
+ end
18
+ end
19
+
20
+ def rebuild_host!
21
+ @host = build_host
22
+ @host_layout = build_host_layout
23
+ scroll_area.set_widget(host)
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QTimetrap
4
+ module Entries
5
+ # Internal state and debounce helpers for entries list component.
6
+ module ListStateHelpers
7
+ private
8
+
9
+ def initialize_state!
10
+ initialize_tree_state!
11
+ initialize_time_filter_state!
12
+ end
13
+
14
+ def initialize_tree_state!
15
+ @expanded = {}
16
+ @current_nodes = []
17
+ @branch_bindings = {}
18
+ @leaf_labels = []
19
+ @entry_rows = []
20
+ @rendering = false
21
+ end
22
+
23
+ def initialize_time_filter_state!
24
+ @time_filter_from_toggle = nil
25
+ @time_filter_to_toggle = nil
26
+ @time_filter_from_input = nil
27
+ @time_filter_to_input = nil
28
+ @time_filter_debounce = nil
29
+ @syncing_time_filters = false
30
+ end
31
+
32
+ def set_time_filter_state(toggle:, input:, value:)
33
+ enabled = !value.nil?
34
+ toggle.set_checked(enabled)
35
+ input.set_date_time(value) if enabled
36
+ end
37
+
38
+ def syncing_time_filters?
39
+ @syncing_time_filters
40
+ end
41
+
42
+ def schedule_time_range_filter_changed
43
+ return if syncing_time_filters?
44
+ return unless on_time_range_change
45
+
46
+ time_filter_debounce.stop if time_filter_debounce.is_active
47
+ time_filter_debounce.start
48
+ end
49
+
50
+ def build_time_filter_debounce_timer
51
+ QTimer.new(parent).tap do |timer|
52
+ timer.set_single_shot(true)
53
+ timer.set_interval(self.class::TIME_FILTER_DEBOUNCE_MS)
54
+ timer.connect('timeout') { |_| emit_time_range_filter_changed }
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QTimetrap
4
+ module Entries
5
+ # Node text/object-name helpers for entries tree rendering.
6
+ module NodePresentationHelpers
7
+ private
8
+
9
+ def object_name_for(node)
10
+ case node.fetch(:type)
11
+ when :week then 'entry_node_week'
12
+ when :day then 'entry_node_day'
13
+ when :project then 'entry_node_project'
14
+ when :entry then 'entry_node_entry'
15
+ else 'entry_node_empty'
16
+ end
17
+ end
18
+
19
+ def indent(level)
20
+ ' ' * level
21
+ end
22
+
23
+ def branch_button_text(level, label, expanded_state)
24
+ prefix = expanded_state ? '▾' : '▸'
25
+ "#{indent(level)}#{prefix} #{label}"
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QTimetrap
4
+ module Entries
5
+ # Render-cycle helpers to reduce flicker during entries tree rebuild.
6
+ module RenderHelpers
7
+ private
8
+
9
+ def with_widget_updates_suspended
10
+ widget.set_updates_enabled(false)
11
+ yield
12
+ ensure
13
+ widget.set_updates_enabled(true)
14
+ widget.update
15
+ end
16
+
17
+ def render_contents
18
+ rebuild_host!
19
+ render_nodes(current_nodes, 0)
20
+ adjust_node_widths
21
+ host_layout.add_stretch(1)
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QTimetrap
4
+ module Entries
5
+ # Helper methods for entries tree rendering and expand/collapse controls.
6
+ module TreeHelpers
7
+ include BranchHierarchyHelpers
8
+ include LeafArchiveHelpers
9
+ include LeafNoteHelpers
10
+ include LeafTaskHelpers
11
+ include LeafTimeHelpers
12
+ include NodePresentationHelpers
13
+ include TreeToolbarHelpers
14
+
15
+ private
16
+
17
+ def on_filter_toggle_changed
18
+ schedule_time_range_filter_changed
19
+ end
20
+
21
+ def set_initial_filter_ui_state
22
+ time_filter_from_toggle.set_checked(false)
23
+ time_filter_to_toggle.set_checked(false)
24
+ end
25
+
26
+ def emit_time_range_filter_changed
27
+ return if syncing_time_filters?
28
+ return unless on_time_range_change
29
+
30
+ on_time_range_change.call(selected_time_filter(time_filter_from_toggle, time_filter_from_input),
31
+ selected_time_filter(time_filter_to_toggle, time_filter_to_input))
32
+ end
33
+
34
+ def selected_time_filter(toggle, input)
35
+ return nil unless filter_toggle_checked?(toggle)
36
+
37
+ input.date_time
38
+ end
39
+
40
+ def filter_toggle_checked?(toggle)
41
+ value = toggle.is_checked
42
+ [true, 1].include?(value)
43
+ end
44
+
45
+ def expand_all!
46
+ set_all_branch_nodes(current_nodes, true)
47
+ apply_all_branch_states
48
+ end
49
+
50
+ def collapse_all!
51
+ set_all_branch_nodes(current_nodes, false)
52
+ apply_all_branch_states
53
+ end
54
+
55
+ def set_all_branch_nodes(nodes, value)
56
+ nodes.each do |node|
57
+ next unless branch_node?(node)
58
+
59
+ expanded[node.fetch(:id)] = value
60
+ set_all_branch_nodes(node.fetch(:children), value)
61
+ end
62
+ end
63
+
64
+ def render_nodes(nodes, level, layout: host_layout, parent_widget: host)
65
+ nodes.each { |node| render_node(node, level, layout: layout, parent_widget: parent_widget) }
66
+ end
67
+
68
+ def render_node(node, level, layout:, parent_widget:)
69
+ if branch_node?(node)
70
+ render_branch_node(node, level, layout: layout, parent_widget: parent_widget)
71
+ else
72
+ render_leaf_node(node, level, layout: layout, parent_widget: parent_widget)
73
+ end
74
+ end
75
+
76
+ def render_branch_node(node, level, layout:, parent_widget:)
77
+ node_id = node.fetch(:id)
78
+ label = node.fetch(:label)
79
+ expanded_state = expanded.fetch(node.fetch(:id), true)
80
+ button = build_branch_button(node, level, expanded_state, parent_widget: parent_widget)
81
+ layout.add_widget(button)
82
+ children_container, children_layout = build_children_container(parent_widget)
83
+ layout.add_widget(children_container)
84
+ register_branch_binding(
85
+ node_id: node_id,
86
+ button: button,
87
+ children_container: children_container,
88
+ label: label,
89
+ level: level
90
+ )
91
+ render_nodes(node.fetch(:children), level + 1, layout: children_layout, parent_widget: children_container)
92
+ apply_branch_state(node_id, expanded_state)
93
+ end
94
+
95
+ def render_leaf_node(node, level, layout:, parent_widget:)
96
+ if node.fetch(:type) == :entry
97
+ render_entry_leaf_node(node, level, layout: layout, parent_widget: parent_widget)
98
+ return
99
+ end
100
+
101
+ render_default_leaf_node(node, level, parent_widget: parent_widget, layout: layout)
102
+ end
103
+
104
+ def render_default_leaf_node(node, level, parent_widget:, layout:)
105
+ label = QLabel.new(parent_widget)
106
+ label.set_object_name(object_name_for(node))
107
+ label.set_text("#{indent(level)}#{node.fetch(:label)}")
108
+ label.set_fixed_width(branch_button_width)
109
+ label.set_fixed_height(32)
110
+ leaf_labels << label
111
+ layout.add_widget(label)
112
+ end
113
+
114
+ def toggle_node(node_id)
115
+ expanded[node_id] = !expanded.fetch(node_id, true)
116
+ apply_branch_state(node_id, expanded[node_id])
117
+ end
118
+
119
+ def branch_node?(node)
120
+ !node.fetch(:children).empty?
121
+ end
122
+
123
+ def build_branch_button(node, level, expanded_state, parent_widget:)
124
+ text = branch_button_text(level, node.fetch(:label), expanded_state)
125
+ button = build_button(parent_widget, object_name_for(node), text, 0, 32)
126
+ button.set_fixed_width(branch_button_width)
127
+ node_id = node.fetch(:id)
128
+ button.connect('clicked') { |_| toggle_node(node_id) }
129
+ button
130
+ end
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QTimetrap
4
+ module Entries
5
+ # Toolbar builders for entries tree controls and time-range filters.
6
+ module TreeToolbarHelpers
7
+ private
8
+
9
+ def build_toolbar(parent_widget:)
10
+ toolbar = QWidget.new(parent_widget)
11
+ toolbar.set_object_name('entries_toolbar')
12
+ layout = build_toolbar_layout(toolbar)
13
+ add_tree_toolbar_buttons(layout, toolbar)
14
+ add_time_filter_controls(layout, toolbar)
15
+ set_initial_filter_ui_state
16
+ layout.add_stretch(1)
17
+ toolbar
18
+ end
19
+
20
+ def build_toolbar_layout(toolbar)
21
+ QHBoxLayout.new(toolbar).tap do |layout|
22
+ layout.set_contents_margins(0, 0, 0, 0)
23
+ layout.set_spacing(8)
24
+ end
25
+ end
26
+
27
+ def add_tree_toolbar_buttons(layout, toolbar)
28
+ add_toolbar_button(layout, toolbar, 'entries_expand_all', 'EXPAND ALL') { expand_all! }
29
+ add_toolbar_button(layout, toolbar, 'entries_collapse_all', 'COLLAPSE ALL') { collapse_all! }
30
+ layout.add_spacing(8)
31
+ end
32
+
33
+ def add_toolbar_button(layout, toolbar, name, text, &)
34
+ layout.add_widget(build_toolbar_button(toolbar, name, text, &))
35
+ end
36
+
37
+ def add_time_filter_controls(layout, toolbar)
38
+ @time_filter_from_toggle = add_filter_control(
39
+ layout: layout,
40
+ toolbar: toolbar,
41
+ toggle_name: 'entries_time_filter_from_toggle',
42
+ toggle_text: 'FROM',
43
+ input_name: 'entries_time_filter_from'
44
+ )
45
+ @time_filter_to_toggle = add_filter_control(
46
+ layout: layout,
47
+ toolbar: toolbar,
48
+ toggle_name: 'entries_time_filter_to_toggle',
49
+ toggle_text: 'TO',
50
+ input_name: 'entries_time_filter_to'
51
+ )
52
+ end
53
+
54
+ def add_filter_control(layout:, toolbar:, toggle_name:, toggle_text:, input_name:)
55
+ toggle = build_filter_toggle(toolbar, toggle_name, toggle_text)
56
+ layout.add_widget(toggle)
57
+ input = build_filter_input(toolbar, input_name)
58
+ layout.add_widget(input)
59
+ assign_filter_input(toggle_name, input)
60
+ toggle
61
+ end
62
+
63
+ def assign_filter_input(toggle_name, input)
64
+ if toggle_name == 'entries_time_filter_from_toggle'
65
+ @time_filter_from_input = input
66
+ else
67
+ @time_filter_to_input = input
68
+ end
69
+ end
70
+
71
+ def build_toolbar_button(parent_widget, name, text)
72
+ build_button(parent_widget, name, text, 136, 28).tap { |button| button.connect('clicked') { |_| yield } }
73
+ end
74
+
75
+ def build_filter_toggle(parent_widget, name, text)
76
+ QCheckBox.new(parent_widget).tap do |checkbox|
77
+ checkbox.set_object_name(name)
78
+ checkbox.set_text(text)
79
+ checkbox.set_focus_policy(Qt::NoFocus)
80
+ checkbox.set_fixed_height(28)
81
+ checkbox.connect('clicked') { |_| on_filter_toggle_changed }
82
+ end
83
+ end
84
+
85
+ def build_filter_input(parent_widget, name)
86
+ QDateTimeEdit.new(parent_widget).tap do |input|
87
+ input.set_object_name(name)
88
+ input.set_focus_policy(Qt::ClickFocus)
89
+ input.set_fixed_width(172)
90
+ input.set_calendar_popup(true)
91
+ input.set_display_format('yyyy-MM-dd HH:mm')
92
+ input.set_date_time(Time.now)
93
+ input.connect('dateTimeChanged(QDateTime)') { |_| schedule_time_range_filter_changed }
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QTimetrap
4
+ module ProjectSidebar
5
+ # Bottom archive mode toggle button in sidebar.
6
+ module ArchiveToggleHelpers
7
+ private
8
+
9
+ def build_archive_toggle_button
10
+ QPushButton.new(widget).tap do |button|
11
+ button.set_object_name('sidebar_archive_toggle')
12
+ button.set_checkable(true)
13
+ button.set_focus_policy(Qt::NoFocus)
14
+ button.set_fixed_height(30)
15
+ button.set_text("\u{1F5C3}")
16
+ button.set_tool_tip('Show archived entries only')
17
+ button.connect('clicked') { |_| on_archive_mode_toggled&.call(button.is_checked) }
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end