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,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QTimetrap
4
+ module ProjectSidebar
5
+ # Displays project shortcuts and notifies on project selection.
6
+ class Component
7
+ include ArchiveToggleHelpers
8
+ include LogoHelpers
9
+ include ProjectButtonHelpers
10
+ include ProjectSelectionHelpers
11
+ include TaskHelpers
12
+
13
+ attr_reader :widget
14
+
15
+ def initialize(
16
+ parent:,
17
+ on_project_selected:,
18
+ on_task_selected: nil,
19
+ on_archive_mode_toggled: nil
20
+ )
21
+ @parent = parent
22
+ @on_project_selected = on_project_selected
23
+ @on_task_selected = on_task_selected
24
+ @on_archive_mode_toggled = on_archive_mode_toggled
25
+ initialize_selection_state
26
+ build
27
+ end
28
+
29
+ def render(projects:, tasks: [], selection: {}, selected_task: nil, archive_mode: false)
30
+ values = Array(projects)
31
+ selection = selection_state(selection)
32
+ render_projects(projects: values, selection: selection)
33
+ render_tasks(tasks: tasks, selected_project: selection.fetch(:selected_project), selected_task: selected_task)
34
+ archive_toggle_button.set_checked(archive_mode)
35
+ end
36
+
37
+ private
38
+
39
+ attr_reader :parent, :on_project_selected, :on_task_selected, :buttons, :buttons_layout, :task_buttons,
40
+ :task_buttons_layout, :tasks_heading, :on_archive_mode_toggled, :archive_toggle_button
41
+
42
+ def build
43
+ @widget = QWidget.new(parent)
44
+ widget.set_object_name('sidebar_panel')
45
+ layout = build_root_layout
46
+ add_static_sidebar_sections(layout)
47
+ @buttons_layout = build_buttons_layout
48
+ layout.add_layout(buttons_layout)
49
+ add_tasks_section(layout)
50
+ layout.add_stretch(1)
51
+ @archive_toggle_button = build_archive_toggle_button
52
+ layout.add_widget(archive_toggle_button)
53
+ end
54
+
55
+ def render_slot(slot, project, index:)
56
+ slot[:project] = project
57
+ view = slot[:view]
58
+
59
+ view.set_text(project[0, 24])
60
+ view.set_disabled(false)
61
+ view.set_checked(selected_project_indices.include?(index))
62
+ view.show
63
+ end
64
+
65
+ def sync_project_buttons(target_count)
66
+ while buttons.size < target_count
67
+ button = build_project_button
68
+ buttons_layout.add_widget(button)
69
+ buttons << { view: button, project: nil }
70
+ end
71
+
72
+ while buttons.size > target_count
73
+ slot = buttons.pop
74
+ slot.fetch(:view).hide
75
+ end
76
+ end
77
+
78
+ def build_root_layout
79
+ QVBoxLayout.new(widget).tap do |layout|
80
+ layout.set_contents_margins(12, 12, 12, 12)
81
+ layout.set_spacing(8)
82
+ end
83
+ end
84
+
85
+ def initialize_selection_state
86
+ @buttons = []
87
+ @selected_project_indices = []
88
+ @last_project_anchor_index = nil
89
+ @project_values = []
90
+ @task_buttons = []
91
+ @selected_task_indices = []
92
+ @last_task_anchor_index = nil
93
+ @task_values = []
94
+ end
95
+
96
+ def add_static_sidebar_sections(layout)
97
+ layout.add_widget(build_logo)
98
+ layout.add_widget(build_logo_spacer)
99
+ layout.add_widget(build_heading)
100
+ end
101
+
102
+ def add_tasks_section(layout)
103
+ @tasks_heading = build_tasks_heading
104
+ tasks_heading.hide
105
+ layout.add_widget(tasks_heading)
106
+ @task_buttons_layout = build_buttons_layout
107
+ layout.add_layout(task_buttons_layout)
108
+ end
109
+
110
+ def selection_state(selection)
111
+ {
112
+ selected_project: selection.fetch(:selected_project, '* ALL'),
113
+ selected_projects: selection.fetch(:selected_projects, ['* ALL'])
114
+ }
115
+ end
116
+
117
+ def build_buttons_layout
118
+ QVBoxLayout.new.tap do |layout|
119
+ layout.set_contents_margins(0, 0, 0, 0)
120
+ layout.set_spacing(8)
121
+ end
122
+ end
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QTimetrap
4
+ module ProjectSidebar
5
+ # Sidebar header/logo construction helpers.
6
+ module LogoHelpers
7
+ private
8
+
9
+ def build_logo
10
+ QWidget.new(widget).tap do |container|
11
+ container.set_object_name('sidebar_logo')
12
+ layout = QHBoxLayout.new(container)
13
+ layout.set_contents_margins(0, 0, 0, 0)
14
+ layout.set_spacing(8)
15
+ layout.add_stretch(1)
16
+ layout.add_widget(build_logo_icon(container))
17
+ layout.add_widget(build_logo_text(container))
18
+ layout.add_stretch(1)
19
+ end
20
+ end
21
+
22
+ def build_logo_icon(parent_widget)
23
+ QLabel.new(parent_widget).tap do |label|
24
+ label.set_object_name('sidebar_logo_icon')
25
+ label.set_alignment(Qt::AlignCenter)
26
+ label.set_fixed_size(66, 66)
27
+ icon_path = File.join(Application.root, 'app', 'assets', 'icons', 'qtimetrap-icon-128.png')
28
+ label.set_text("<img src='#{icon_path}' width='64' height='64'/>")
29
+ end
30
+ end
31
+
32
+ def build_logo_text(parent_widget)
33
+ QLabel.new(parent_widget).tap do |label|
34
+ label.set_object_name('sidebar_logo_text')
35
+ label.set_alignment(Qt::AlignCenter)
36
+ label.set_text('QTimetrap')
37
+ end
38
+ end
39
+
40
+ def build_logo_spacer
41
+ QWidget.new(widget).tap { |spacer| spacer.set_fixed_height(6) }
42
+ end
43
+
44
+ def build_heading
45
+ QLabel.new(widget).tap do |label|
46
+ label.set_object_name('sidebar_heading')
47
+ label.set_alignment(Qt::AlignCenter)
48
+ label.set_text('PROJECTS')
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QTimetrap
4
+ module ProjectSidebar
5
+ # Project button rendering and interaction behavior for the sidebar.
6
+ module ProjectButtonHelpers
7
+ private
8
+
9
+ def render_projects(projects:, selection:)
10
+ refresh_project_state(projects, selection.fetch(:selected_projects), selection.fetch(:selected_project))
11
+ sync_project_buttons(projects.size)
12
+ rerender_project_buttons(projects)
13
+ end
14
+
15
+ def sync_project_buttons(target_count)
16
+ while buttons.size < target_count
17
+ button = build_project_button
18
+ buttons_layout.add_widget(button)
19
+ buttons << { view: button, project: nil }
20
+ end
21
+
22
+ while buttons.size > target_count
23
+ slot = buttons.pop
24
+ slot.fetch(:view).hide
25
+ end
26
+ end
27
+
28
+ def render_slot(slot, project, index:)
29
+ slot[:project] = project
30
+ view = slot[:view]
31
+
32
+ view.set_text(project[0, 24])
33
+ view.set_disabled(false)
34
+ view.set_checked(selected_project_indices.include?(index))
35
+ view.show
36
+ end
37
+
38
+ def build_project_button
39
+ QPushButton.new(widget).tap do |button|
40
+ button.set_object_name('project_button')
41
+ button.set_checkable(true)
42
+ button.set_focus_policy(Qt::NoFocus)
43
+ button.set_fixed_height(30)
44
+ button.connect('clicked') { |_| on_button_clicked(button) }
45
+ end
46
+ end
47
+
48
+ def on_button_clicked(button)
49
+ index, item = selected_project_button(button)
50
+ return unless index && item
51
+
52
+ apply_project_selection(index)
53
+ rerender_project_buttons(project_values)
54
+ on_project_selected.call(selected_project_values, item[:project])
55
+ end
56
+
57
+ def selected_project_button(button)
58
+ index = buttons.index { |candidate| candidate[:view] == button }
59
+ return [nil, nil] unless index
60
+
61
+ item = buttons.fetch(index)
62
+ return [nil, nil] unless item[:project]
63
+
64
+ [index, item]
65
+ end
66
+
67
+ def rerender_project_buttons(projects)
68
+ buttons.each_with_index { |slot, index| render_slot(slot, projects[index], index: index) }
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QTimetrap
4
+ module ProjectSidebar
5
+ # Selection state behavior for sidebar project shortcuts.
6
+ module ProjectSelectionHelpers
7
+ private
8
+
9
+ def refresh_project_state(values, selected_projects, selected_project)
10
+ values_changed = project_values != values
11
+ @project_values = values.dup
12
+ return normalize_project_selection(selected_project) unless values_changed
13
+
14
+ @selected_project_indices = []
15
+ @last_project_anchor_index = nil
16
+ normalized = normalize_selected_projects(values, selected_projects, selected_project)
17
+ @selected_project_indices = normalized.filter_map { |project| values.index(project) }
18
+ @last_project_anchor_index = values.index(selected_project) || selected_project_indices.last
19
+ end
20
+
21
+ def normalize_project_selection(selected_project)
22
+ @selected_project_indices = in_range_project_indices
23
+ normalized = normalize_selected_projects(project_values, selected_project_values, selected_project)
24
+ @selected_project_indices = indices_for_projects(normalized)
25
+ @last_project_anchor_index = anchor_index_for(selected_project)
26
+ end
27
+
28
+ def apply_project_selection(index)
29
+ ctrl, shift = selection_modifiers
30
+ if shift && !last_project_anchor_index.nil?
31
+ apply_project_shift_selection(index, ctrl: ctrl)
32
+ elsif ctrl
33
+ toggle_project_index(index)
34
+ else
35
+ @selected_project_indices = [index]
36
+ end
37
+
38
+ @last_project_anchor_index = index
39
+ normalize_project_all_selection
40
+ end
41
+
42
+ def apply_project_shift_selection(index, ctrl:)
43
+ first = [last_project_anchor_index, index].min
44
+ last = [last_project_anchor_index, index].max
45
+ range = (first..last).to_a
46
+ @selected_project_indices = ctrl ? (selected_project_indices | range) : range
47
+ end
48
+
49
+ def toggle_project_index(index)
50
+ @selected_project_indices = if selected_project_indices.include?(index)
51
+ selected_project_indices - [index]
52
+ else
53
+ selected_project_indices + [index]
54
+ end
55
+ end
56
+
57
+ def normalize_project_all_selection
58
+ all_index = project_values.index('* ALL')
59
+ return unless all_index
60
+ return @selected_project_indices = [all_index] if selected_project_indices.empty?
61
+ return unless selected_project_indices.include?(all_index) && selected_project_indices.length > 1
62
+
63
+ @selected_project_indices = [last_project_anchor_index == all_index ? all_index : last_project_anchor_index]
64
+ end
65
+
66
+ def normalize_selected_projects(values, selected_projects, selected_project)
67
+ normalized = Array(selected_projects).map(&:to_s).reject(&:empty?).uniq
68
+ normalized = [selected_project.to_s] if normalized.empty? && !selected_project.to_s.empty?
69
+ normalized &= values
70
+ normalized = ['* ALL'] if normalized.empty? || normalized.include?('* ALL')
71
+ normalized
72
+ end
73
+
74
+ def selected_project_values
75
+ selected_project_indices.filter_map { |index| project_values[index] }
76
+ end
77
+
78
+ def in_range_project_indices
79
+ max_index = project_values.length - 1
80
+ selected_project_indices.select { |index| index <= max_index }
81
+ end
82
+
83
+ def indices_for_projects(projects)
84
+ projects.filter_map { |project| project_values.index(project) }
85
+ end
86
+
87
+ def anchor_index_for(selected_project)
88
+ project_values.index(selected_project) || selected_project_indices.last
89
+ end
90
+
91
+ attr_reader :selected_project_indices, :last_project_anchor_index, :project_values
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QTimetrap
4
+ module ProjectSidebar
5
+ # Sidebar tasks section rendering and interactions.
6
+ module TaskHelpers
7
+ include TaskSelectionHelpers
8
+
9
+ private
10
+
11
+ def render_tasks(tasks:, selected_project:, selected_task:)
12
+ values = Array(tasks)
13
+ visible = tasks_visible?(selected_project, values)
14
+ tasks_heading.set_visible(visible)
15
+ sync_task_buttons(visible ? values.size : 0)
16
+ return clear_task_state unless visible
17
+
18
+ refresh_task_state(values, selected_task)
19
+ fill_task_buttons(values)
20
+ end
21
+
22
+ def sync_task_buttons(target_count)
23
+ while task_buttons.size < target_count
24
+ button = build_task_button
25
+ task_buttons_layout.add_widget(button)
26
+ task_buttons << { view: button, task: nil }
27
+ end
28
+
29
+ while task_buttons.size > target_count
30
+ slot = task_buttons.pop
31
+ slot.fetch(:view).hide
32
+ end
33
+ end
34
+
35
+ def build_tasks_heading
36
+ QLabel.new(widget).tap do |label|
37
+ label.set_object_name('sidebar_tasks_heading')
38
+ label.set_alignment(Qt::AlignCenter)
39
+ label.set_text('TASKS')
40
+ end
41
+ end
42
+
43
+ def build_task_button
44
+ QPushButton.new(widget).tap do |button|
45
+ button.set_object_name('task_button')
46
+ button.set_checkable(true)
47
+ button.set_focus_policy(Qt::NoFocus)
48
+ button.set_fixed_height(28)
49
+ button.connect('clicked') { |_| on_task_button_clicked(button) }
50
+ end
51
+ end
52
+
53
+ def on_task_button_clicked(button)
54
+ index = task_buttons.index { |candidate| candidate[:view] == button }
55
+ return unless index
56
+
57
+ item = task_buttons.fetch(index)
58
+ return unless item && item[:task]
59
+
60
+ apply_task_selection(index)
61
+ fill_task_buttons(task_values)
62
+ return unless on_task_selected
63
+
64
+ on_task_selected.call(selected_task_values, item[:task])
65
+ end
66
+
67
+ def tasks_visible?(selected_project, values)
68
+ selected_project != '* ALL' && !values.empty?
69
+ end
70
+
71
+ def fill_task_buttons(values)
72
+ task_buttons.each_with_index do |slot, index|
73
+ update_task_button(slot: slot, task: values[index], index: index)
74
+ end
75
+ end
76
+
77
+ def update_task_button(slot:, task:, index:)
78
+ view = slot.fetch(:view)
79
+ slot[:task] = task
80
+ text = task.to_s
81
+ view.set_text(text)
82
+ view.set_tool_tip(text)
83
+ view.set_checked(selected_task_indices.include?(index))
84
+ view.show
85
+ end
86
+
87
+ def selected_task_values
88
+ selected_task_indices.filter_map { |index| task_values[index] }
89
+ end
90
+
91
+ attr_reader :selected_task_indices, :last_task_anchor_index, :task_values
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QTimetrap
4
+ module ProjectSidebar
5
+ # Selection state behavior for sidebar task shortcuts.
6
+ module TaskSelectionHelpers
7
+ private
8
+
9
+ def refresh_task_state(values, selected_task)
10
+ values_changed = task_values != values
11
+ @task_values = values.dup
12
+ return normalize_task_selection unless values_changed
13
+
14
+ @selected_task_indices = []
15
+ @last_task_anchor_index = nil
16
+ return unless selected_task
17
+
18
+ index = values.index(selected_task)
19
+ return unless index
20
+
21
+ @selected_task_indices = [index]
22
+ @last_task_anchor_index = index
23
+ end
24
+
25
+ def clear_task_state
26
+ @task_values = []
27
+ @selected_task_indices = []
28
+ @last_task_anchor_index = nil
29
+ end
30
+
31
+ def normalize_task_selection
32
+ max_index = task_values.length - 1
33
+ @selected_task_indices = selected_task_indices.select { |index| index <= max_index }
34
+ @last_task_anchor_index = nil unless selected_task_indices.include?(last_task_anchor_index)
35
+ end
36
+
37
+ def apply_task_selection(index)
38
+ ctrl, shift = selection_modifiers
39
+ if shift && !last_task_anchor_index.nil?
40
+ apply_shift_selection(index, ctrl: ctrl)
41
+ elsif ctrl
42
+ toggle_task_index(index)
43
+ else
44
+ @selected_task_indices = [index]
45
+ end
46
+ @last_task_anchor_index = index
47
+ end
48
+
49
+ def selection_modifiers
50
+ modifiers = QApplication.keyboard_modifiers.to_i
51
+ ctrl = modifiers.anybits?(Qt::ControlModifier)
52
+ shift = modifiers.anybits?(Qt::ShiftModifier)
53
+ [ctrl, shift]
54
+ end
55
+
56
+ def apply_shift_selection(index, ctrl:)
57
+ first = [last_task_anchor_index, index].min
58
+ last = [last_task_anchor_index, index].max
59
+ range = (first..last).to_a
60
+ @selected_task_indices = ctrl ? (selected_task_indices | range) : range
61
+ end
62
+
63
+ def toggle_task_index(index)
64
+ @selected_task_indices = if selected_task_indices.include?(index)
65
+ selected_task_indices - [index]
66
+ else
67
+ selected_task_indices + [index]
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QTimetrap
4
+ # Shared helpers for naming and creating common Qt widgets.
5
+ module QtUiHelpers
6
+ private
7
+
8
+ def build_label(parent_widget, object_name, text, width: nil, height: nil)
9
+ QLabel.new(parent_widget).tap do |label|
10
+ label.set_object_name(object_name)
11
+ label.set_text(text) if text
12
+ label.set_fixed_width(width) if width
13
+ label.set_fixed_height(height) if height
14
+ end
15
+ end
16
+
17
+ def build_button(parent_widget, name, text, width, height)
18
+ QPushButton.new(parent_widget).tap do |button|
19
+ button.set_object_name(name)
20
+ button.set_text(text)
21
+ button.set_focus_policy(Qt::NoFocus)
22
+ button.set_fixed_width(width) if width&.positive?
23
+ button.set_fixed_height(height)
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QTimetrap
4
+ module TrackerControls
5
+ # Contains main tracking controls, summary labels, and theme actions.
6
+ class Component
7
+ TIMER_FONT_MIN_PT = 12
8
+ TIMER_FONT_MAX_PT = 20
9
+ TIMER_CHAR_WIDTH_FACTOR = 0.72
10
+ TIMER_HORIZONTAL_PADDING = 12
11
+ TIMER_FIT_SAMPLE = '000:00:00'
12
+
13
+ attr_reader :task_input, :project_input, :clock_label, :timer_label, :widget
14
+
15
+ def initialize(parent:, callbacks:)
16
+ assign_ui(build_ui(parent:, callbacks:))
17
+ end
18
+
19
+ def update_summary(text)
20
+ summary_label.set_text(text)
21
+ end
22
+
23
+ def update_project_input(project_name)
24
+ value = project_name.to_s.strip
25
+ project_input.set_text(value)
26
+ end
27
+
28
+ def update_task_input(text)
29
+ task_input.text = text.to_s
30
+ end
31
+
32
+ def update_theme_label(theme_name)
33
+ theme_button.set_text("THEME: #{theme_name.upcase}")
34
+ end
35
+
36
+ def update_action_button(running:)
37
+ start_button.set_visible(!running)
38
+ stop_button.set_visible(running)
39
+ task_input.set_read_only(running)
40
+ project_input.set_read_only(running)
41
+ end
42
+
43
+ private
44
+
45
+ attr_reader :summary_label, :theme_button, :start_button, :stop_button
46
+
47
+ def apply_timer_font_fit(text)
48
+ chars = [text.length, 1].max
49
+ available = [timer_label.width - TIMER_HORIZONTAL_PADDING, 24].max
50
+ estimated = (available / (chars * TIMER_CHAR_WIDTH_FACTOR)).floor
51
+ font_size = estimated.clamp(TIMER_FONT_MIN_PT, TIMER_FONT_MAX_PT)
52
+ timer_label.set_style_sheet("font-size: #{font_size}pt;")
53
+ end
54
+
55
+ def build_ui(parent:, callbacks:)
56
+ LayoutBuilder.new(
57
+ parent: parent,
58
+ callbacks: callbacks
59
+ ).build
60
+ end
61
+
62
+ def assign_ui(ui_map)
63
+ @widget = ui_map.fetch(:widget)
64
+ @task_input = ui_map.fetch(:task_input)
65
+ @clock_label = ui_map.fetch(:clock_label)
66
+ @timer_label = ui_map.fetch(:timer_label)
67
+ @summary_label = ui_map.fetch(:summary_label)
68
+ @project_input = ui_map.fetch(:project_input)
69
+ @theme_button = ui_map.fetch(:theme_button)
70
+ @start_button = ui_map.fetch(:start_button)
71
+ @stop_button = ui_map.fetch(:stop_button)
72
+ configure_timer_font_fit!
73
+ end
74
+
75
+ def configure_timer_font_fit!
76
+ apply_timer_font_fit(TIMER_FIT_SAMPLE)
77
+ end
78
+ end
79
+ end
80
+ end