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.
- checksums.yaml +7 -0
- data/LICENSE +25 -0
- data/README.md +92 -0
- data/Rakefile +105 -0
- data/app/assets/icons/qtimetrap-icon-128.png +0 -0
- data/app/assets/icons/qtimetrap-icon-256.png +0 -0
- data/app/assets/icons/qtimetrap-icon-512.png +0 -0
- data/app/assets/icons/qtimetrap-icon.svg +123 -0
- data/app/components/entries/branch_hierarchy_helpers.rb +41 -0
- data/app/components/entries/leaf_archive_helpers.rb +23 -0
- data/app/components/entries/leaf_note_helpers.rb +118 -0
- data/app/components/entries/leaf_task_helpers.rb +81 -0
- data/app/components/entries/leaf_time_helpers.rb +105 -0
- data/app/components/entries/list_component.rb +118 -0
- data/app/components/entries/list_host_helpers.rb +27 -0
- data/app/components/entries/list_state_helpers.rb +59 -0
- data/app/components/entries/node_presentation_helpers.rb +29 -0
- data/app/components/entries/render_helpers.rb +25 -0
- data/app/components/entries/tree_helpers.rb +133 -0
- data/app/components/entries/tree_toolbar_helpers.rb +98 -0
- data/app/components/project_sidebar/archive_toggle_helpers.rb +22 -0
- data/app/components/project_sidebar/component.rb +125 -0
- data/app/components/project_sidebar/logo_helpers.rb +53 -0
- data/app/components/project_sidebar/project_button_helpers.rb +72 -0
- data/app/components/project_sidebar/project_selection_helpers.rb +94 -0
- data/app/components/project_sidebar/task_helpers.rb +94 -0
- data/app/components/project_sidebar/task_selection_helpers.rb +72 -0
- data/app/components/qt_ui_helpers.rb +27 -0
- data/app/components/tracker_controls/component.rb +80 -0
- data/app/components/tracker_controls/layout_builder.rb +100 -0
- data/app/components/tracker_controls/layout_helpers.rb +88 -0
- data/app/models/null_settings_store.rb +12 -0
- data/app/models/time_entry.rb +59 -0
- data/app/services/archived_entries_store.rb +71 -0
- data/app/services/formatters.rb +29 -0
- data/app/services/settings_store.rb +83 -0
- data/app/services/timetrap_gateway.rb +89 -0
- data/app/services/timetrap_gateway_logger.rb +54 -0
- data/app/services/timetrap_gateway_query_helpers.rb +102 -0
- data/app/services/timetrap_gateway_start_helpers.rb +28 -0
- data/app/services/timetrap_gateway_update_note_helpers.rb +48 -0
- data/app/services/timetrap_gateway_update_task_helpers.rb +47 -0
- data/app/services/timetrap_gateway_update_time_helpers.rb +50 -0
- data/app/styles/theme.rb +61 -0
- data/app/styles/themes/dark/application.qss +8 -0
- data/app/styles/themes/dark/entries_list.qss +235 -0
- data/app/styles/themes/dark/project_sidebar.qss +99 -0
- data/app/styles/themes/dark/snippets/app_background.qss +2 -0
- data/app/styles/themes/dark/snippets/button_ghost.qss +5 -0
- data/app/styles/themes/dark/snippets/button_start.qss +5 -0
- data/app/styles/themes/dark/snippets/button_stop.qss +5 -0
- data/app/styles/themes/dark/snippets/entries_host.qss +2 -0
- data/app/styles/themes/dark/snippets/entries_scroll.qss +16 -0
- data/app/styles/themes/dark/snippets/entry_row_day.qss +6 -0
- data/app/styles/themes/dark/snippets/entry_row_detail.qss +5 -0
- data/app/styles/themes/dark/snippets/entry_row_project.qss +6 -0
- data/app/styles/themes/dark/snippets/project_button.qss +5 -0
- data/app/styles/themes/dark/snippets/project_button_active.qss +5 -0
- data/app/styles/themes/dark/snippets/project_sidebar_heading.qss +2 -0
- data/app/styles/themes/dark/snippets/project_sidebar_logo.qss +3 -0
- data/app/styles/themes/dark/snippets/project_sidebar_panel.qss +2 -0
- data/app/styles/themes/dark/snippets/tracker_clock.qss +2 -0
- data/app/styles/themes/dark/snippets/tracker_input.qss +5 -0
- data/app/styles/themes/dark/snippets/tracker_project_label.qss +5 -0
- data/app/styles/themes/dark/snippets/tracker_row.qss +3 -0
- data/app/styles/themes/dark/snippets/tracker_summary.qss +3 -0
- data/app/styles/themes/dark/snippets/tracker_timer.qss +3 -0
- data/app/styles/themes/dark/snippets/tracker_title.qss +3 -0
- data/app/styles/themes/dark/snippets/tracker_topbar.qss +2 -0
- data/app/styles/themes/dark/tracker_controls.qss +84 -0
- data/app/styles/themes/light/application.qss +8 -0
- data/app/styles/themes/light/entries_list.qss +235 -0
- data/app/styles/themes/light/project_sidebar.qss +99 -0
- data/app/styles/themes/light/snippets/app_background.qss +2 -0
- data/app/styles/themes/light/snippets/button_ghost.qss +5 -0
- data/app/styles/themes/light/snippets/button_start.qss +5 -0
- data/app/styles/themes/light/snippets/button_stop.qss +5 -0
- data/app/styles/themes/light/snippets/entries_host.qss +2 -0
- data/app/styles/themes/light/snippets/entries_scroll.qss +16 -0
- data/app/styles/themes/light/snippets/entry_row_day.qss +6 -0
- data/app/styles/themes/light/snippets/entry_row_detail.qss +5 -0
- data/app/styles/themes/light/snippets/entry_row_project.qss +6 -0
- data/app/styles/themes/light/snippets/project_button.qss +5 -0
- data/app/styles/themes/light/snippets/project_button_active.qss +5 -0
- data/app/styles/themes/light/snippets/project_sidebar_heading.qss +2 -0
- data/app/styles/themes/light/snippets/project_sidebar_logo.qss +3 -0
- data/app/styles/themes/light/snippets/project_sidebar_panel.qss +2 -0
- data/app/styles/themes/light/snippets/tracker_clock.qss +2 -0
- data/app/styles/themes/light/snippets/tracker_input.qss +5 -0
- data/app/styles/themes/light/snippets/tracker_project_label.qss +5 -0
- data/app/styles/themes/light/snippets/tracker_row.qss +3 -0
- data/app/styles/themes/light/snippets/tracker_summary.qss +3 -0
- data/app/styles/themes/light/snippets/tracker_timer.qss +3 -0
- data/app/styles/themes/light/snippets/tracker_title.qss +3 -0
- data/app/styles/themes/light/snippets/tracker_topbar.qss +2 -0
- data/app/styles/themes/light/tracker_controls.qss +84 -0
- data/app/view_models/entry_nodes_builder.rb +109 -0
- data/app/view_models/main_view_model.rb +135 -0
- data/app/view_models/main_view_model_archive_mode_helpers.rb +102 -0
- data/app/view_models/main_view_model_entry_note_helpers.rb +30 -0
- data/app/view_models/main_view_model_entry_task_helpers.rb +39 -0
- data/app/view_models/main_view_model_entry_time_helpers.rb +45 -0
- data/app/view_models/main_view_model_sheet_helpers.rb +74 -0
- data/app/view_models/main_view_model_task_filter_helpers.rb +58 -0
- data/app/view_models/main_view_model_time_range_filter_helpers.rb +23 -0
- data/app/views/main_window.rb +126 -0
- data/app/views/main_window_layout_builder.rb +114 -0
- data/app/views/main_window_runtime.rb +123 -0
- data/app/views/main_window_runtime_archive_helpers.rb +26 -0
- data/app/views/main_window_runtime_entry_task_helpers.rb +21 -0
- data/app/views/main_window_runtime_entry_time_helpers.rb +17 -0
- data/app/views/main_window_runtime_key_helpers.rb +55 -0
- data/app/views/main_window_runtime_render_helpers.rb +50 -0
- data/app/views/main_window_splitter_toggle_bootstrap_helpers.rb +34 -0
- data/app/views/main_window_splitter_toggle_helpers.rb +103 -0
- data/app/views/main_window_splitter_toggle_hover_helpers.rb +71 -0
- data/app/views/main_window_splitter_toggle_layout_helpers.rb +24 -0
- data/app/views/main_window_ui_helpers.rb +45 -0
- data/app/views/window_icon_loader.rb +37 -0
- data/bin/qtimetrap +7 -0
- data/config/application.rb +87 -0
- data/config/environments/development.rb +5 -0
- data/config/environments/production.rb +5 -0
- data/config/initializers/theme.rb +5 -0
- data/config/initializers/timetrap.rb +5 -0
- data/lib/qtimetrap/configuration.rb +15 -0
- data/lib/qtimetrap/container.rb +57 -0
- data/lib/qtimetrap/version.rb +5 -0
- data/lib/qtimetrap.rb +25 -0
- 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
|