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,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QTimetrap
4
+ module Views
5
+ # Main Qt window wiring together components and user interactions.
6
+ class MainWindow
7
+ include MainWindowRuntime
8
+ include MainWindowUiHelpers
9
+
10
+ WINDOW_W = 1380
11
+ WINDOW_H = 860
12
+ THEMES = %w[light dark].freeze
13
+ HEARTBEAT_MS = 33
14
+
15
+ def initialize(
16
+ view_model: ViewModels::MainViewModel.new,
17
+ theme: Styles::Theme.new(name: 'light', root: Application.root),
18
+ settings_store: Models::NullSettingsStore.new
19
+ )
20
+ @view_model = view_model
21
+ @theme = theme
22
+ @settings_store = settings_store
23
+ @pending_refresh = true
24
+ @shutdown_requested = false
25
+
26
+ build_window
27
+ connect_key_events
28
+ connect_shortcuts
29
+ connect_heartbeat
30
+ end
31
+
32
+ def show = window.show
33
+
34
+ def close
35
+ persist_window_geometry
36
+ window.close
37
+ end
38
+
39
+ def request_shutdown = @shutdown_requested = true
40
+
41
+ private
42
+
43
+ attr_reader :view_model, :window, :theme, :settings_store, :sidebar, :controls, :entries, :heartbeat
44
+
45
+ def build_window
46
+ @window = build_base_window
47
+ restore_window_geometry
48
+ set_window_icon
49
+ window.set_style_sheet(theme.application_stylesheet)
50
+ ui = MainWindowLayoutBuilder.new(
51
+ window: window,
52
+ callbacks: layout_callbacks
53
+ ).build
54
+ @sidebar = ui.fetch(:sidebar)
55
+ @controls = ui.fetch(:controls)
56
+ @entries = ui.fetch(:entries)
57
+ end
58
+
59
+ def connect_heartbeat
60
+ @heartbeat = QTimer.new(window)
61
+ heartbeat.set_interval(HEARTBEAT_MS)
62
+ heartbeat.connect('timeout') { |_| on_tick }
63
+ heartbeat.start
64
+ end
65
+
66
+ def connect_key_events
67
+ window.on(:key_press) { |event| on_key_press(event) }
68
+ register_blur_click_source(window)
69
+ register_blur_click_source(sidebar.widget)
70
+ register_blur_click_source(controls.widget)
71
+ register_blur_click_source(entries.widget)
72
+ end
73
+
74
+ def connect_shortcuts
75
+ @space_shortcut = QShortcut.new(QKeySequence.new('Space'), window)
76
+ @space_shortcut.connect('activated') { |_| on_space_shortcut }
77
+ end
78
+
79
+ def register_blur_click_source(widget)
80
+ widget.on(:mouse_button_press) { |event| on_mouse_button_press(event, source_widget: widget) }
81
+ end
82
+
83
+ def restore_window_geometry
84
+ geometry = settings_store.read_window_geometry
85
+ return unless geometry
86
+
87
+ window.set_geometry(
88
+ geometry.fetch(:left),
89
+ geometry.fetch(:top),
90
+ geometry.fetch(:width),
91
+ geometry.fetch(:height)
92
+ )
93
+ end
94
+
95
+ def persist_window_geometry
96
+ settings_store.write_window_geometry(
97
+ left: window.x,
98
+ top: window.y,
99
+ width: window.width,
100
+ height: window.height
101
+ )
102
+ rescue StandardError => e
103
+ warn("[qtimetrap] save geometry failed: #{e.class}: #{e.message}")
104
+ end
105
+
106
+ def layout_callbacks
107
+ {
108
+ on_project_selected: method(:handle_project_selected),
109
+ on_task_selected: method(:handle_task_selected),
110
+ on_archive_mode_toggled: method(:handle_archive_mode_toggled),
111
+ on_start: method(:handle_start),
112
+ on_project_change: method(:handle_project_input),
113
+ on_time_range_change: method(:handle_time_range_changed),
114
+ on_entry_note_change: method(:handle_entry_note_changed),
115
+ on_entry_task_change: method(:handle_entry_task_changed),
116
+ on_entry_time_change: method(:handle_entry_time_changed),
117
+ task_suggestions_for_project: method(:task_suggestions_for_project),
118
+ on_entry_archive: method(:handle_entry_archived),
119
+ on_stop: method(:handle_stop),
120
+ on_refresh: -> { @pending_refresh = true },
121
+ on_switch_theme: method(:switch_theme!)
122
+ }
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QTimetrap
4
+ module Views
5
+ # Builds main window layout and returns initialized component instances.
6
+ class MainWindowLayoutBuilder
7
+ include MainWindowSplitterToggleHelpers
8
+
9
+ SIDEBAR_WIDTH = 220
10
+ SIDEBAR_MIN_WIDTH = 180
11
+ SIDEBAR_MAX_WIDTH = 520
12
+
13
+ def initialize(window:, callbacks:)
14
+ @window = window
15
+ @callbacks = callbacks
16
+ end
17
+
18
+ def build
19
+ root = QHBoxLayout.new(window)
20
+ root.set_contents_margins(0, 0, 0, 0)
21
+ root.set_spacing(0)
22
+ splitter, sidebar, controls, entries = build_splitter_and_content
23
+ root.add_widget(splitter)
24
+ { sidebar: sidebar, controls: controls, entries: entries }
25
+ end
26
+
27
+ private
28
+
29
+ attr_reader :window, :callbacks
30
+
31
+ def build_splitter_and_content
32
+ splitter = QSplitter.new(window)
33
+ splitter.set_orientation(Qt::Horizontal)
34
+ splitter.set_mouse_tracking(true)
35
+ sidebar = build_sidebar(parent: splitter)
36
+ content, controls, entries = build_content(parent: splitter)
37
+ splitter.add_widget(sidebar.widget)
38
+ splitter.add_widget(content)
39
+ configure_splitter(splitter)
40
+ add_sidebar_toggle_button(window: window, splitter: splitter, sidebar_widget: sidebar.widget)
41
+ [splitter, sidebar, controls, entries]
42
+ end
43
+
44
+ def configure_splitter(splitter)
45
+ splitter.set_stretch_factor(0, 0)
46
+ splitter.set_stretch_factor(1, 1)
47
+ splitter.set_collapsible(0, false)
48
+ splitter.set_collapsible(1, false)
49
+ end
50
+
51
+ def build_sidebar(parent:)
52
+ QTimetrap::ProjectSidebar::Component.new(
53
+ parent: parent,
54
+ on_project_selected: callbacks.fetch(:on_project_selected),
55
+ on_task_selected: callbacks.fetch(:on_task_selected),
56
+ on_archive_mode_toggled: callbacks.fetch(:on_archive_mode_toggled)
57
+ ).tap do |component|
58
+ component.widget.set_base_size(SIDEBAR_WIDTH, 0)
59
+ component.widget.set_minimum_width(SIDEBAR_MIN_WIDTH)
60
+ component.widget.set_maximum_width(SIDEBAR_MAX_WIDTH)
61
+ end
62
+ end
63
+
64
+ def build_content(parent:)
65
+ content, layout = build_content_widget(parent: parent)
66
+ controls = build_controls(content)
67
+ entries = build_entries_component(content)
68
+ layout.add_widget(controls.widget)
69
+ layout.add_widget(entries.widget)
70
+ layout.set_stretch(1, 1)
71
+ [content, controls, entries]
72
+ end
73
+
74
+ def build_entries_component(parent)
75
+ QTimetrap::Entries::ListComponent.new(
76
+ parent: parent,
77
+ callbacks: entries_callbacks,
78
+ task_suggestions_for_project: callbacks.fetch(:task_suggestions_for_project)
79
+ )
80
+ end
81
+
82
+ def entries_callbacks
83
+ {
84
+ on_entry_note_change: callbacks.fetch(:on_entry_note_change),
85
+ on_entry_task_change: callbacks.fetch(:on_entry_task_change),
86
+ on_entry_time_change: callbacks.fetch(:on_entry_time_change),
87
+ on_entry_archive: callbacks.fetch(:on_entry_archive),
88
+ on_time_range_change: callbacks.fetch(:on_time_range_change)
89
+ }
90
+ end
91
+
92
+ def build_content_widget(parent:)
93
+ content = QWidget.new(parent)
94
+ layout = QVBoxLayout.new(content)
95
+ layout.set_contents_margins(14, 8, 14, 8)
96
+ layout.set_spacing(10)
97
+ [content, layout]
98
+ end
99
+
100
+ def build_controls(content)
101
+ QTimetrap::TrackerControls::Component.new(
102
+ parent: content,
103
+ callbacks: {
104
+ on_start: callbacks.fetch(:on_start),
105
+ on_stop: callbacks.fetch(:on_stop),
106
+ on_refresh: callbacks.fetch(:on_refresh),
107
+ on_switch_theme: callbacks.fetch(:on_switch_theme),
108
+ on_project_change: callbacks.fetch(:on_project_change)
109
+ }
110
+ )
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QTimetrap
4
+ module Views
5
+ # Event/runtime behavior extracted from MainWindow.
6
+ module MainWindowRuntime
7
+ include MainWindowRuntimeArchiveHelpers
8
+ include MainWindowRuntimeEntryTaskHelpers
9
+ include MainWindowRuntimeEntryTimeHelpers
10
+ include MainWindowRuntimeKeyHelpers
11
+ include MainWindowRuntimeRenderHelpers
12
+
13
+ private
14
+
15
+ def on_tick
16
+ return unless window.is_visible
17
+ return close if @shutdown_requested
18
+
19
+ now = Time.now
20
+ update_live_indicators(now)
21
+ refresh_if_needed
22
+ end
23
+
24
+ def update_live_indicators(now)
25
+ controls.clock_label.set_text(now.strftime('%a %d %b %Y %H:%M:%S'))
26
+ controls.timer_label.set_text(view_model.running_timer_line(now: now))
27
+ end
28
+
29
+ def refresh_if_needed
30
+ return unless @pending_refresh
31
+
32
+ view_model.refresh!
33
+ render!(sync_sheet: true)
34
+ @pending_refresh = false
35
+ end
36
+
37
+ def handle_start(task_input, project_name)
38
+ task_name = resolved_start_task(task_input)
39
+ project = resolved_start_project(project_name)
40
+ view_model.current_project_name = project
41
+ view_model.current_task_input = task_name
42
+ view_model.start_tracking(view_model.sheet_for_task_input(task_name))
43
+ @pending_refresh = true
44
+ rescue StandardError => e
45
+ warn("[qtimetrap] start failed: #{e.class}: #{e.message}")
46
+ end
47
+
48
+ def handle_stop
49
+ view_model.stop_tracking
50
+ @pending_refresh = true
51
+ rescue StandardError => e
52
+ warn("[qtimetrap] stop failed: #{e.class}: #{e.message}")
53
+ end
54
+
55
+ def handle_project_selected(projects, project)
56
+ update_current_fields = !view_model.running_current_sheet?
57
+ view_model.select_projects(projects, primary_project: project, sync_current_fields: update_current_fields)
58
+ view_model.current_project_name = project if update_current_fields && project != '* ALL'
59
+ render!
60
+ return unless update_current_fields
61
+
62
+ controls.update_task_input(view_model.current_sheet_input)
63
+ controls.update_project_input(project == '* ALL' ? '' : project)
64
+ end
65
+
66
+ def handle_task_selected(tasks, task)
67
+ view_model.select_tasks(tasks)
68
+ unless view_model.running_current_sheet?
69
+ view_model.current_task_input = task.to_s
70
+ controls.update_task_input(view_model.current_sheet_input)
71
+ end
72
+ render_controls(sync_sheet: false)
73
+ entries.render(view_model.entry_nodes)
74
+ end
75
+
76
+ def handle_project_input(project_name)
77
+ return if view_model.running_current_sheet?
78
+
79
+ view_model.current_project_name = project_name
80
+ end
81
+
82
+ def handle_time_range_changed(from_at, to_at)
83
+ view_model.update_time_range_filter(from_at: from_at, to_at: to_at)
84
+ render_controls(sync_sheet: false)
85
+ entries.update_time_range_inputs(
86
+ from_at: view_model.time_filter_from_at,
87
+ to_at: view_model.time_filter_to_at
88
+ )
89
+ entries.render(view_model.entry_nodes)
90
+ rescue StandardError => e
91
+ warn("[qtimetrap] update time-range failed: #{e.class}: #{e.message}")
92
+ end
93
+
94
+ def handle_entry_note_changed(entry_id, note)
95
+ view_model.update_entry_note(entry_id, note)
96
+ rescue StandardError => e
97
+ warn("[qtimetrap] update note failed: #{e.class}: #{e.message}")
98
+ end
99
+
100
+ def resolved_start_task(fallback_task)
101
+ value = controls.task_input.text.to_s.strip
102
+ return value unless value.empty?
103
+
104
+ fallback_task.to_s.strip
105
+ end
106
+
107
+ def resolved_start_project(fallback)
108
+ value = controls.project_input.text.to_s.strip
109
+ return value unless value.empty?
110
+
111
+ fallback.to_s.strip
112
+ end
113
+
114
+ def switch_theme!
115
+ @theme = theme.with_name(next_theme_name)
116
+ apply_theme
117
+ render!
118
+ rescue StandardError => e
119
+ warn("[qtimetrap] save theme failed: #{e.class}: #{e.message}")
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QTimetrap
4
+ module Views
5
+ # Archive handlers for MainWindow runtime.
6
+ module MainWindowRuntimeArchiveHelpers
7
+ private
8
+
9
+ def handle_archive_mode_toggled(enabled)
10
+ view_model.archive_mode = enabled
11
+ render!(sync_sheet: false)
12
+ end
13
+
14
+ def handle_entry_archived(entry_id)
15
+ archive_mode_active? ? view_model.unarchive_entry(entry_id) : view_model.archive_entry(entry_id)
16
+ @pending_refresh = true
17
+ rescue StandardError => e
18
+ warn("[qtimetrap] archive entry failed: #{e.class}: #{e.message}")
19
+ end
20
+
21
+ def archive_mode_active?
22
+ view_model.archive_mode?
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QTimetrap
4
+ module Views
5
+ # Entry task move handlers for MainWindow runtime.
6
+ module MainWindowRuntimeEntryTaskHelpers
7
+ private
8
+
9
+ def handle_entry_task_changed(entry_id, task_name)
10
+ view_model.update_entry_task(entry_id, task_name)
11
+ @pending_refresh = true
12
+ rescue StandardError => e
13
+ warn("[qtimetrap] update task failed: #{e.class}: #{e.message}")
14
+ end
15
+
16
+ def task_suggestions_for_project(project_name)
17
+ view_model.task_names_for_project(project_name)
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QTimetrap
4
+ module Views
5
+ # Entry time change runtime behavior for MainWindow.
6
+ module MainWindowRuntimeEntryTimeHelpers
7
+ private
8
+
9
+ def handle_entry_time_changed(entry_id, start_text, end_text)
10
+ view_model.update_entry_time(entry_id, start_text, end_text)
11
+ @pending_refresh = true
12
+ rescue StandardError => e
13
+ warn("[qtimetrap] update time failed: #{e.class}: #{e.message}")
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QTimetrap
4
+ module Views
5
+ # Keyboard shortcut handlers for MainWindow runtime.
6
+ module MainWindowRuntimeKeyHelpers
7
+ private
8
+
9
+ def on_key_press(event)
10
+ key = extract_event_value(event, :a) || 0
11
+ modifiers = extract_event_value(event, :b) || 0
12
+ ctrl_mask = Qt::ControlModifier
13
+ quit_key = Qt::Key_Q
14
+ request_shutdown if modifiers.anybits?(ctrl_mask) && key == quit_key
15
+ end
16
+
17
+ def on_space_shortcut
18
+ return if active_line_edit?
19
+
20
+ toggle_tracking_via_space
21
+ end
22
+
23
+ def on_mouse_button_press(event, source_widget: window)
24
+ focused = window.focus_widget
25
+ return unless editable_line_edit?(focused)
26
+
27
+ target = click_target_widget(source_widget, event)
28
+ return if editable_line_edit?(target)
29
+
30
+ focused.clear_focus
31
+ end
32
+
33
+ def active_line_edit?
34
+ focused = window.focus_widget
35
+ editable_line_edit?(focused)
36
+ end
37
+
38
+ def toggle_tracking_via_space
39
+ if view_model.running_current_sheet?
40
+ handle_stop
41
+ else
42
+ handle_start(controls.task_input.text.to_s, controls.project_input.text.to_s)
43
+ end
44
+ end
45
+
46
+ def click_target_widget(source_widget, event)
47
+ source_widget.child_at(extract_event_value(event, :a), extract_event_value(event, :b))
48
+ end
49
+
50
+ def editable_line_edit?(widget)
51
+ widget.is_a?(QLineEdit) && !widget.is_read_only
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QTimetrap
4
+ module Views
5
+ # Rendering helpers for MainWindow runtime loop and interactions.
6
+ module MainWindowRuntimeRenderHelpers
7
+ private
8
+
9
+ def render!(sync_sheet: false)
10
+ render_sidebar
11
+ render_controls(sync_sheet: sync_sheet)
12
+ render_entries_panel
13
+ end
14
+
15
+ def render_sidebar
16
+ sidebar.render(
17
+ projects: view_model.project_names,
18
+ tasks: view_model.task_names_for_selected_project,
19
+ selection: {
20
+ selected_project: view_model.selected_project,
21
+ selected_projects: view_model.selected_projects
22
+ },
23
+ selected_task: view_model.selected_tasks.first,
24
+ archive_mode: view_model.archive_mode?
25
+ )
26
+ end
27
+
28
+ def render_entries_panel
29
+ entries.update_time_range_inputs(
30
+ from_at: view_model.time_filter_from_at,
31
+ to_at: view_model.time_filter_to_at
32
+ )
33
+ entries.render(view_model.entry_nodes)
34
+ end
35
+
36
+ def render_controls(sync_sheet:)
37
+ controls.update_summary(view_model.summary_line)
38
+ update_tracking_controls(sync_sheet: sync_sheet)
39
+ controls.update_theme_label(theme.name)
40
+ end
41
+
42
+ def update_tracking_controls(sync_sheet:)
43
+ controls.update_task_input(view_model.current_sheet_input) if sync_sheet
44
+ controls.update_action_button(running: view_model.running_current_sheet?)
45
+ project_name = view_model.current_project_name.to_s
46
+ controls.update_project_input(project_name) unless project_name.strip.empty?
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QTimetrap
4
+ module Views
5
+ # Bootstrap helpers to stabilize initial splitter toggle placement.
6
+ module MainWindowSplitterToggleBootstrapHelpers
7
+ private
8
+
9
+ def schedule_initial_toggle_reposition(splitter:, sidebar_widget:, button:, zone:)
10
+ attempts = { count: 0 }
11
+ context = { splitter: splitter, sidebar_widget: sidebar_widget, button: button, zone: zone }
12
+ timer = QTimer.new(zone)
13
+ timer.set_interval(120)
14
+ timer.connect('timeout') do |_|
15
+ tick_initial_reposition(
16
+ attempts: attempts,
17
+ timer: timer,
18
+ context: context
19
+ )
20
+ end
21
+ timer.start
22
+ end
23
+
24
+ def tick_initial_reposition(attempts:, timer:, context:)
25
+ attempts[:count] += 1
26
+ reposition_toggle_affordance(**context)
27
+ return unless attempts[:count] >= 4
28
+
29
+ timer.stop
30
+ timer.delete_later
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QTimetrap
4
+ module Views
5
+ # Sidebar collapse/expand toggle attached to splitter handle area.
6
+ module MainWindowSplitterToggleHelpers
7
+ include MainWindowSplitterToggleLayoutHelpers
8
+ include MainWindowSplitterToggleBootstrapHelpers
9
+ include MainWindowSplitterToggleHoverHelpers
10
+
11
+ SIDEBAR_TOGGLE_W = 20
12
+ SIDEBAR_TOGGLE_H = 56
13
+ SIDEBAR_TOGGLE_ZONE_W = 24
14
+ SIDEBAR_TOGGLE_ZONE_H = 128
15
+
16
+ private
17
+
18
+ def add_sidebar_toggle_button(window:, splitter:, sidebar_widget:)
19
+ button = build_sidebar_toggle_button(window)
20
+ zone = build_sidebar_toggle_zone(window)
21
+ state = { collapsed: false, button_hovered: false, zone_hovered: false }
22
+ button.connect('clicked') { |_| on_sidebar_toggle_clicked(splitter, sidebar_widget, button, zone, state) }
23
+ bind_splitter_toggle_events(
24
+ splitter: splitter,
25
+ sidebar_widget: sidebar_widget,
26
+ button: button,
27
+ zone: zone,
28
+ state: state
29
+ )
30
+ bind_toggle_button_events(button: button, state: state)
31
+ reposition_toggle_affordance(splitter: splitter, sidebar_widget: sidebar_widget, button: button, zone: zone)
32
+ schedule_initial_toggle_reposition(
33
+ splitter: splitter,
34
+ sidebar_widget: sidebar_widget,
35
+ button: button,
36
+ zone: zone
37
+ )
38
+ end
39
+
40
+ def build_sidebar_toggle_button(window)
41
+ QPushButton.new(window).tap do |button|
42
+ button.set_object_name('sidebar_toggle_button')
43
+ button.set_text('◀')
44
+ button.set_focus_policy(Qt::NoFocus)
45
+ button.set_tool_tip('Collapse sidebar')
46
+ button.set_fixed_size(SIDEBAR_TOGGLE_W, SIDEBAR_TOGGLE_H)
47
+ button.raise
48
+ button.hide
49
+ end
50
+ end
51
+
52
+ def on_sidebar_toggle_clicked(splitter, sidebar_widget, button, zone, state)
53
+ state[:collapsed] = !state[:collapsed]
54
+ update_sidebar_visibility(sidebar_widget, button, collapsed: state[:collapsed])
55
+ reposition_toggle_affordance(splitter: splitter, sidebar_widget: sidebar_widget, button: button, zone: zone)
56
+ end
57
+
58
+ def build_sidebar_toggle_zone(window)
59
+ QWidget.new(window).tap do |zone|
60
+ zone.set_object_name('sidebar_toggle_hotspot')
61
+ zone.set_fixed_size(SIDEBAR_TOGGLE_ZONE_W, SIDEBAR_TOGGLE_ZONE_H)
62
+ zone.set_style_sheet('background: transparent;')
63
+ zone.raise
64
+ end
65
+ end
66
+
67
+ def bind_splitter_toggle_events(splitter:, sidebar_widget:, button:, zone:, state:)
68
+ splitter.connect('splitterMoved') do |_|
69
+ reposition_toggle_affordance(splitter: splitter, sidebar_widget: sidebar_widget, button: button, zone: zone)
70
+ end
71
+ splitter.on(:resize) do |_|
72
+ reposition_toggle_affordance(splitter: splitter, sidebar_widget: sidebar_widget, button: button, zone: zone)
73
+ end
74
+ sidebar_widget.on(:resize) do |_|
75
+ reposition_toggle_affordance(splitter: splitter, sidebar_widget: sidebar_widget, button: button, zone: zone)
76
+ end
77
+ bind_toggle_zone_events(zone: zone, button: button, state: state)
78
+ end
79
+
80
+ def reposition_toggle_affordance(splitter:, sidebar_widget:, button:, zone:)
81
+ x = toggle_x(splitter: splitter, sidebar_widget: sidebar_widget)
82
+ y = toggle_y(splitter_height: splitter.height)
83
+ button.move(x, y)
84
+ button.raise
85
+ zone_x = x - ((SIDEBAR_TOGGLE_ZONE_W - SIDEBAR_TOGGLE_W) / 2)
86
+ zone_y = y - ((SIDEBAR_TOGGLE_ZONE_H - SIDEBAR_TOGGLE_H) / 2)
87
+ zone.move(zone_x, zone_y)
88
+ end
89
+
90
+ def update_sidebar_visibility(sidebar_widget, button, collapsed:)
91
+ if collapsed
92
+ sidebar_widget.hide
93
+ button.set_text('▶')
94
+ button.set_tool_tip('Expand sidebar')
95
+ else
96
+ sidebar_widget.show
97
+ button.set_text('◀')
98
+ button.set_tool_tip('Collapse sidebar')
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end