qtimetrap 0.1.2 → 0.1.3

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5287d989740a397f7ed84b151eb5d3d48ab8edd480bb580517cfdedc8e20bb54
4
- data.tar.gz: 6234dfac9679d2b577999f83b561226d03bc70201743f8df8c4668d0e8c5fe57
3
+ metadata.gz: '018f44eb75f1594bcb9a78ac9f9cf8663901e2c3bf34d76f324f6e5a955da4d1'
4
+ data.tar.gz: b0c0f032ae783bb94d12061186c8430596ae7c5dbcef6497337113517bebc6b5
5
5
  SHA512:
6
- metadata.gz: 140de50f2fde21743d1f58e108ceed523906dc79d2971ae519a45dda8fbdce3e5850f6a179c926580d6b3436450b2dd56f96a725619dc09fc8191fb3127db18c
7
- data.tar.gz: 1cb985651e8bc995f9ad5d9fe554c5ef151a11a2bdcd88c31882c872d17bf765ab3cb3f1aaaf6924dc1bd44aef947c47a991b55447a76f2c5f29c93f950f9ec2
6
+ metadata.gz: 10055a894a818debb7f33541ca4146fe65eb46e0bb364f10d0d7d0cc8bee9d3ee0421658fd9848d186a264d4fa2bd9c1830c166c5e11cdf990ed39bdc5cb4bf7
7
+ data.tar.gz: c1b33688d08191603e914254c48a1405393dc21482041e8a9f7a5587dd104a012bd8e0ff3b0c5093be0a2743e0a2dd8982e9c0f32570fb599806ad454ee45e20
data/CHANGELOG.md CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [0.1.3] - 2026-11-10
6
+
7
+ ### Performance
8
+ - Cache archived-entry IDs during refresh.
9
+ - Render the entry list incrementally and filter existing widgets instead of rebuilding the tree.
10
+ - Create task editors only when activated to reduce initial widget construction.
11
+ - Add a startup and interaction profiling script.
12
+
5
13
  ## [0.1.2] - 2026-03-17
6
14
 
7
15
  ### Added
data/README.md CHANGED
@@ -19,7 +19,7 @@ QTimetrap follows an MVVM-style architecture with `Zeitwerk` autoloading and a R
19
19
  ## Requirements
20
20
 
21
21
  - Ruby `>= 3.2`
22
- - Qt bridge gem: `qt >= 0.1.4`
22
+ - Qt bridge gem: `qt >= 0.1.7`
23
23
  - `timetrap` CLI available as `t` (or configured via env)
24
24
 
25
25
  ## Install
@@ -38,9 +38,8 @@ Install required system libraries first:
38
38
  ### Fedora COPR
39
39
 
40
40
  ```bash
41
- sudo dnf copr enable cyjimmy264/ruby-qt
42
- sudo dnf copr enable cyjimmy264/ruby-qtimetrap
43
- sudo dnf install ruby-qt ruby-qtimetrap
41
+ sudo dnf copr enable cyjimmy264/ruby-qt-stack
42
+ sudo dnf install ruby-qtimetrap
44
43
  gem install timetrap
45
44
  qtimetrap
46
45
  ```
@@ -0,0 +1,196 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QTimetrap
4
+ module Entries
5
+ # Builds editable entry rows in small GUI-thread steps.
6
+ module EntryRowRenderHelpers
7
+ private
8
+
9
+ def start_entry_row_render(node, level, layout:, parent_widget:)
10
+ row = build_entry_row(parent_widget)
11
+ apply_entry_widget_styles(row, ListComponent::ENTRY_ROW_STYLE_IDS)
12
+ row_layout = build_entry_row_layout(row)
13
+ layout.add_widget(row)
14
+ entry_rows << row
15
+ entry_id = node.fetch(:entry_id, node.fetch(:id))
16
+ entry_rows_by_id[entry_id] = row
17
+ project_branch_id = container_branch_ids[parent_widget.handle.address]
18
+ entry_row_parent_branches[entry_id] = project_branch_id
19
+ project_branch_entry_ids[project_branch_id] << entry_id if project_branch_id
20
+ row_visible = visible_entry_ids.nil? || visible_entry_ids.key?(entry_id)
21
+ row_filter_state[entry_id] = row_visible
22
+ row.visible = row_visible
23
+
24
+ context = {
25
+ node: node,
26
+ level: level,
27
+ row: row,
28
+ row_layout: row_layout,
29
+ parent_layout: layout,
30
+ parent_widget: parent_widget
31
+ }
32
+ enqueue_entry_row_stage(context, :time_group)
33
+ end
34
+
35
+ def enqueue_entry_row_stage(context, stage, index = nil)
36
+ render_queue << [:entry_stage, stage, context, index]
37
+ end
38
+
39
+ def process_entry_row_stage(context, stage, index)
40
+ case stage
41
+ when :time_group then create_entry_time_group(context)
42
+ when :start_time then create_entry_start_time(context)
43
+ when :time_separator then create_entry_time_separator(context)
44
+ when :end_time then create_entry_end_time(context)
45
+ when :bind_start_time then bind_entry_start_time(context)
46
+ when :bind_end_time then bind_entry_end_time(context)
47
+ when :prefix then create_entry_prefix(context)
48
+ when :task_container then create_task_container(context)
49
+ when :task_display then create_task_display(context)
50
+ when :note_input then create_entry_note(context)
51
+ when :note_bind_mouse then bind_entry_note_mouse(context)
52
+ when :note_bind_key then bind_entry_note_key(context)
53
+ when :note_bind_return then bind_entry_note_return(context)
54
+ when :note_bind_focus then bind_entry_note_focus(context)
55
+ when :archive_button then create_entry_archive_button(context)
56
+ when :finish then finish_entry_row(context)
57
+ end
58
+ end
59
+
60
+ def create_entry_time_group(context)
61
+ group = QWidget.new(context.fetch(:row))
62
+ group.object_name = 'entry_node_entry_time_group'
63
+ layout = QHBoxLayout.new(group)
64
+ layout.set_contents_margins(0, 0, 0, 0)
65
+ layout.spacing = 4
66
+ context[:time_group] = group
67
+ context[:time_layout] = layout
68
+ context.fetch(:row_layout).add_widget(group)
69
+ enqueue_entry_row_stage(context, :start_time)
70
+ end
71
+
72
+ def create_entry_start_time(context)
73
+ node = context.fetch(:node)
74
+ input = build_entry_time_input(
75
+ context.fetch(:row),
76
+ object_name: 'entry_node_entry_start',
77
+ value: node.fetch(:start_label, '--:--'),
78
+ placeholder: '--:--'
79
+ )
80
+ context[:start_input] = input
81
+ context.fetch(:time_layout).add_widget(input)
82
+ enqueue_entry_row_stage(context, :time_separator)
83
+ end
84
+
85
+ def create_entry_time_separator(context)
86
+ separator = build_entry_time_separator(context.fetch(:time_group))
87
+ context[:time_layout].add_widget(separator)
88
+ enqueue_entry_row_stage(context, :end_time)
89
+ end
90
+
91
+ def create_entry_end_time(context)
92
+ node = context.fetch(:node)
93
+ input = build_entry_time_input(
94
+ context.fetch(:row),
95
+ object_name: 'entry_node_entry_end',
96
+ value: node.fetch(:end_label, 'running'),
97
+ placeholder: 'running'
98
+ )
99
+ context[:end_input] = input
100
+ context.fetch(:time_layout).add_widget(input)
101
+ enqueue_entry_row_stage(context, :bind_start_time)
102
+ end
103
+
104
+ def bind_entry_start_time(context)
105
+ bind_single_time_input(
106
+ context.fetch(:start_input),
107
+ resolve_entry_id(context.fetch(:node)),
108
+ context.fetch(:start_input),
109
+ context.fetch(:end_input)
110
+ )
111
+ enqueue_entry_row_stage(context, :bind_end_time)
112
+ end
113
+
114
+ def bind_entry_end_time(context)
115
+ bind_single_time_input(
116
+ context.fetch(:end_input),
117
+ resolve_entry_id(context.fetch(:node)),
118
+ context.fetch(:start_input),
119
+ context.fetch(:end_input)
120
+ )
121
+ enqueue_entry_row_stage(context, :prefix)
122
+ end
123
+
124
+ def create_entry_prefix(context)
125
+ label = build_entry_prefix_label(context.fetch(:row), context.fetch(:node), context.fetch(:level))
126
+ context.fetch(:row_layout).add_widget(label)
127
+ enqueue_entry_row_stage(context, :task_container)
128
+ end
129
+
130
+ def create_task_container(context)
131
+ container = QWidget.new(context.fetch(:row))
132
+ container.object_name = 'entry_node_entry_task_container'
133
+ build_entry_task_layout(container)
134
+ context[:task_container] = container
135
+ context.fetch(:row_layout).add_widget(container)
136
+ enqueue_entry_row_stage(context, :task_display)
137
+ end
138
+
139
+ def create_task_display(context)
140
+ node = context.fetch(:node)
141
+ task_name = node.fetch(:task_name, '').to_s
142
+ display = build_entry_task_display(context.fetch(:task_container), task_name)
143
+ context[:task_name] = task_name
144
+ context[:task_display] = display
145
+ task_container = context.fetch(:task_container)
146
+ register_task_widgets(task_container, display, nil, nil)
147
+ register_task_editor_config(task_container, node: context.fetch(:node), task_name: task_name)
148
+ display.on(:mouse_button_press) { |_| activate_entry_task_input(task_container) }
149
+ enqueue_entry_row_stage(context, :note_input)
150
+ end
151
+
152
+ def create_entry_note(context)
153
+ input = build_entry_note_input_widget(context.fetch(:row), context.fetch(:node))
154
+ context[:note_input] = input
155
+ context.fetch(:row_layout).add_widget(input)
156
+ enqueue_entry_row_stage(context, :note_bind_mouse)
157
+ end
158
+
159
+ def bind_entry_note_mouse(context)
160
+ input = context.fetch(:note_input)
161
+ input.on(:mouse_button_press) { |_| activate_entry_note_input(input) }
162
+ enqueue_entry_row_stage(context, :note_bind_key)
163
+ end
164
+
165
+ def bind_entry_note_key(context)
166
+ input = context.fetch(:note_input)
167
+ entry_id = resolve_entry_id(context.fetch(:node))
168
+ input.on(:key_press) { |event| handle_entry_note_key_press(input, entry_id, event) }
169
+ enqueue_entry_row_stage(context, :note_bind_return)
170
+ end
171
+
172
+ def bind_entry_note_return(context)
173
+ input = context.fetch(:note_input)
174
+ entry_id = resolve_entry_id(context.fetch(:node))
175
+ input.connect('returnPressed') { |_| handle_entry_note_commit(input, entry_id) }
176
+ enqueue_entry_row_stage(context, :note_bind_focus)
177
+ end
178
+
179
+ def bind_entry_note_focus(context)
180
+ context.fetch(:note_input).on(:focus_out) { |_| handle_entry_note_focus_out(context.fetch(:note_input)) }
181
+ enqueue_entry_row_stage(context, :archive_button)
182
+ end
183
+
184
+ def create_entry_archive_button(context)
185
+ button = build_entry_archive_button(context.fetch(:row), context.fetch(:node))
186
+ context.fetch(:row_layout).add_widget(button)
187
+ enqueue_entry_row_stage(context, :finish)
188
+ end
189
+
190
+ def finish_entry_row(context)
191
+ context.fetch(:row_layout).set_stretch(2, 1)
192
+ context.fetch(:row_layout).set_stretch(3, 2)
193
+ end
194
+ end
195
+ end
196
+ end
@@ -8,6 +8,7 @@ module QTimetrap
8
8
 
9
9
  def render_entry_leaf_node(node, level, layout:, parent_widget:)
10
10
  row = build_entry_row(parent_widget)
11
+ apply_entry_widget_styles(row, ListComponent::ENTRY_ROW_STYLE_IDS)
11
12
  row_layout = build_entry_row_layout(row)
12
13
  start_input, end_input = add_entry_time_widgets(row_layout, row, node)
13
14
  add_entry_row_widgets(row_layout, row, node, level)
@@ -52,12 +53,17 @@ module QTimetrap
52
53
  end
53
54
 
54
55
  def build_entry_note_input(row, node)
56
+ build_entry_note_input_widget(row, node).tap do |note_input|
57
+ bind_entry_note_input_events(note_input, resolve_entry_id(node))
58
+ end
59
+ end
60
+
61
+ def build_entry_note_input_widget(row, node)
55
62
  QLineEdit.new(row).tap do |note_input|
56
63
  note_input.object_name = 'entry_node_entry_note'
57
64
  note_input.text = node.fetch(:note, '')
58
65
  note_input.placeholder_text = '(no note)'
59
66
  note_input.read_only = true
60
- bind_entry_note_input_events(note_input, resolve_entry_id(node))
61
67
  end
62
68
  end
63
69
 
@@ -6,14 +6,9 @@ module QTimetrap
6
6
  module LeafTaskDisplayHelpers
7
7
  private
8
8
 
9
- def reset_task_display_viewport(task_input)
10
- task_input.cursor_position = 0
11
- end
12
-
13
9
  def sync_task_display_field(task_input, task_name)
14
10
  task_input.text = task_name
15
11
  task_input.tool_tip = task_name
16
- reset_task_display_viewport(task_input)
17
12
  end
18
13
  end
19
14
  end
@@ -8,10 +8,9 @@ module QTimetrap
8
8
 
9
9
  def activate_entry_task_input(task_container)
10
10
  close_previous_task_editor(task_container)
11
+ editor, editor_input = ensure_entry_task_editor(task_container)
11
12
  show_entry_task_editor(task_container)
12
13
  self.active_task_container = task_container
13
- editor = entry_task_editor(task_container)
14
- editor_input = entry_task_editor_line_edit(editor)
15
14
  editor_input.read_only = false
16
15
  editor.set_focus
17
16
  editor_input.set_focus
@@ -31,25 +31,37 @@ module QTimetrap
31
31
  end
32
32
 
33
33
  def build_entry_task_display(task_container, task_name)
34
- QLineEdit.new(task_container).tap do |task_input|
34
+ QLabel.new(task_container).tap do |task_input|
35
35
  task_input.object_name = 'entry_node_entry_task'
36
36
  task_input.text = task_name
37
37
  task_input.tool_tip = task_name
38
- task_input.placeholder_text = 'task'
39
38
  task_input.minimum_width = 220
40
- task_input.read_only = true
39
+ task_input.fixed_height = 24
41
40
  sync_task_display_field(task_input, task_name)
42
41
  task_container.layout.add_widget(task_input)
43
42
  end
44
43
  end
45
44
 
46
45
  def build_entry_task_editor(task_container, task_name, node)
47
- QComboBox.new(task_container).tap do |editor|
48
- line_edit = configure_entry_task_editor(editor, task_container, task_name, node)
49
- task_container.layout.add_widget(editor)
50
- editor.hide
51
- return [editor, line_edit]
52
- end
46
+ editor, line_edit = build_entry_task_editor_shell(task_container, task_name)
47
+ populate_entry_task_options(editor, task_name, node)
48
+ editor.current_text = task_name
49
+ [editor, line_edit]
50
+ end
51
+
52
+ def build_entry_task_editor_shell(task_container, task_name)
53
+ editor = QComboBox.new(task_container)
54
+ editor.object_name = 'entry_node_entry_task_editor'
55
+ editor.editable = true
56
+ editor.focus_policy = Qt::StrongFocus
57
+ editor.minimum_width = 220
58
+ editor.tool_tip = task_name
59
+ line_edit = build_entry_task_line_edit(task_container)
60
+ editor.instance_variable_set(:@qtimetrap_task_line_edit, line_edit)
61
+ editor.line_edit = line_edit
62
+ task_container.layout.add_widget(editor)
63
+ editor.hide
64
+ [editor, line_edit]
53
65
  end
54
66
 
55
67
  def configure_entry_task_editor(editor, task_container, task_name, node)
@@ -65,6 +77,31 @@ module QTimetrap
65
77
  line_edit
66
78
  end
67
79
 
80
+ def ensure_entry_task_editor(task_container)
81
+ editor = entry_task_editor(task_container)
82
+ return [editor, entry_task_editor_line_edit(editor)] if editor
83
+
84
+ config = task_editor_config_for(task_container)
85
+ task_name = config.fetch(:task_name)
86
+ editor, line_edit = build_entry_task_editor_shell(task_container, task_name)
87
+ apply_entry_widget_styles(editor, %w[entry_node_entry_task_editor])
88
+ populate_entry_task_options(editor, task_name, config.fetch(:node))
89
+ editor.current_text = task_name
90
+ register_task_editor_widgets(task_container, editor, line_edit)
91
+ bind_entry_task_editor_events(task_container, config.fetch(:node))
92
+ [editor, line_edit]
93
+ end
94
+
95
+ def bind_entry_task_editor_events(task_container, node)
96
+ entry_id = resolve_entry_id(node)
97
+ editor = entry_task_editor(task_container)
98
+ line_edit = entry_task_editor_line_edit(editor)
99
+ line_edit.on(:key_press) { |event| handle_entry_task_key_press(task_container, entry_id, event) }
100
+ line_edit.connect('returnPressed') { |_| handle_entry_task_commit(task_container, entry_id) }
101
+ line_edit.on(:focus_out) { |_| handle_entry_task_focus_out(task_container) }
102
+ editor.connect('textActivated') { |_| handle_entry_task_commit(task_container, entry_id, force: true) }
103
+ end
104
+
68
105
  def build_entry_task_line_edit(task_container)
69
106
  QLineEdit.new(task_container).tap do |line_edit|
70
107
  line_edit.object_name = 'entry_node_entry_task_editor_input'
@@ -100,6 +137,8 @@ module QTimetrap
100
137
  end
101
138
 
102
139
  def entry_task_editor_line_edit(editor)
140
+ return unless editor
141
+
103
142
  task_editor_line_edit_for(editor) || editor.instance_variable_get(:@qtimetrap_task_line_edit)
104
143
  end
105
144
 
@@ -7,6 +7,7 @@ module QTimetrap
7
7
  include ListHostHelpers
8
8
  include ListStateHelpers
9
9
  include ListTaskEditorHelpers
10
+ include EntryRowRenderHelpers
10
11
  include QtUiHelpers
11
12
  include TreeHelpers
12
13
  include RenderHelpers
@@ -14,6 +15,7 @@ module QTimetrap
14
15
  HOST_HORIZONTAL_MARGINS = 28
15
16
  WIDTH_PADDING = 24
16
17
  TIME_FILTER_DEBOUNCE_MS = 220
18
+ RENDER_SLICE_MS = 4
17
19
 
18
20
  attr_reader :widget
19
21
 
@@ -30,16 +32,89 @@ module QTimetrap
30
32
  end
31
33
 
32
34
  def render(nodes)
33
- return if rendering
34
-
35
+ time_filter_debounce.stop if time_filter_debounce&.is_active
36
+ render_timer.stop if render_timer.is_active
37
+ @theme_style_queue = []
38
+ @render_generation = render_generation.to_i + 1
35
39
  @rendering = true
36
40
  @current_nodes = Array(nodes)
37
41
  @branch_bindings = {}
38
42
  @leaf_labels = []
39
43
  @entry_rows = []
40
- with_widget_updates_suspended { render_contents }
41
- ensure
42
- @rendering = false
44
+ @entry_rows_by_id = {}
45
+ @entry_row_parent_branches = {}
46
+ @project_branch_entry_ids = {}
47
+ @container_branch_ids = {}
48
+ @row_filter_state = {}
49
+ @width_queue = []
50
+ @visibility_queue = []
51
+ @visible_branch_ids = nil
52
+ @visible_branch_labels = {}
53
+ @visible_entry_ids = nil
54
+ @task_suggestion_cache = {}
55
+ @render_needs_stretch = true
56
+ rebuild_host!
57
+ @empty_label = QLabel.new(host)
58
+ @empty_label.object_name = 'entry_node_empty'
59
+ @empty_label.fixed_height = 32
60
+ @empty_label.visible = false
61
+ host_layout.add_widget(@empty_label)
62
+ @render_queue = []
63
+ enqueue_render_nodes(current_nodes, level: 0, layout: host_layout, parent_widget: host)
64
+ render_timer.start
65
+ nil
66
+ end
67
+
68
+ def filter_to(nodes)
69
+ branch_nodes = {}
70
+ entry_ids = {}
71
+ project_entry_ids = Hash.new { |hash, key| hash[key] = [] }
72
+ empty_label = nil
73
+ visit = lambda do |collection, project_branch_id = nil|
74
+ Array(collection).each do |node|
75
+ if node.fetch(:type) == :entry
76
+ entry_id = node.fetch(:entry_id, node.fetch(:id))
77
+ entry_ids[entry_id] = true
78
+ project_entry_ids[project_branch_id] << entry_id if project_branch_id
79
+ elsif node.fetch(:type) == :empty
80
+ empty_label = node.fetch(:label)
81
+ elsif branch_node?(node)
82
+ branch_nodes[node.fetch(:id)] = node
83
+ next_project_id = node.fetch(:type) == :project ? node.fetch(:id) : project_branch_id
84
+ visit.call(node.fetch(:children), next_project_id)
85
+ end
86
+ end
87
+ end
88
+ visit.call(nodes)
89
+
90
+ @visible_branch_ids = branch_nodes
91
+ @visible_branch_labels = branch_nodes.transform_values { |node| node.fetch(:label) }
92
+ @visible_entry_ids = entry_ids
93
+ @visible_project_entry_ids = project_entry_ids
94
+ @visibility_queue = branch_bindings.filter_map do |node_id, binding|
95
+ visible = branch_nodes.key?(node_id)
96
+ label = branch_nodes[node_id]&.fetch(:label)
97
+ next if binding[:filter_visible] == visible && (!visible || binding[:label] == label)
98
+
99
+ [:branch, node_id]
100
+ end
101
+ project_branch_entry_ids.each do |branch_id, entry_keys|
102
+ next unless project_entry_ids.key?(branch_id)
103
+
104
+ visible_ids = project_entry_ids.fetch(branch_id)
105
+ entry_keys.each do |entry_id|
106
+ visible = visible_ids.include?(entry_id)
107
+ next if row_filter_state[entry_id] == visible
108
+
109
+ @visibility_queue << [:entry, entry_id, entry_rows_by_id.fetch(entry_id), visible]
110
+ end
111
+ end
112
+ @visibility_queue << [:empty, empty_label]
113
+ if visibility_queue.any?
114
+ @rendering = true
115
+ render_timer.start
116
+ end
117
+ nil
43
118
  end
44
119
 
45
120
  def update_time_range_inputs(from_at:, to_at:)
@@ -58,7 +133,34 @@ module QTimetrap
58
133
  @syncing_time_filters = false
59
134
  end
60
135
 
136
+ def apply_theme_stylesheet(stylesheet, external_targets: [], defer: false)
137
+ @entry_stylesheet = stylesheet
138
+ @entry_scroll_stylesheet = stylesheet_for_ids(stylesheet, %w[entries_scroll])
139
+ @entry_toolbar_stylesheet = stylesheet_for_ids(stylesheet, %w[
140
+ entries_toolbar entries_expand_all entries_collapse_all
141
+ entries_time_filter_from_toggle entries_time_filter_to_toggle
142
+ entries_time_filter_from entries_time_filter_to
143
+ ])
144
+ @entry_host_stylesheet = stylesheet_for_ids(stylesheet, %w[entries_host])
145
+ @entry_row_stylesheet = stylesheet_for_ids(stylesheet, ENTRY_ROW_STYLE_IDS)
146
+ @entry_empty_stylesheet = stylesheet_for_ids(stylesheet, %w[entry_node_empty])
147
+ @entry_branch_stylesheets = %w[week day project].to_h do |type|
148
+ name = "entry_node_#{type}"
149
+ [name, stylesheet_for_ids(stylesheet, [name])]
150
+ end
151
+ @theme_style_queue = theme_style_targets(stylesheet).concat(external_targets)
152
+ @rendering = true if defer && theme_style_queue.any?
153
+ unless defer
154
+ scroll_area.style_sheet = @entry_scroll_stylesheet
155
+ toolbar.style_sheet = @entry_toolbar_stylesheet
156
+ @theme_style_queue.clear
157
+ end
158
+ render_timer.start if theme_style_queue.any?
159
+ nil
160
+ end
161
+
61
162
  def shutdown
163
+ render_timer.stop if render_timer&.is_active
62
164
  return unless time_filter_debounce
63
165
 
64
166
  time_filter_debounce.stop if time_filter_debounce.is_active
@@ -70,19 +172,201 @@ module QTimetrap
70
172
  :rendering, :scroll_area, :on_entry_note_change, :on_entry_task_change, :task_suggestions_for_project,
71
173
  :on_entry_time_change, :on_entry_archive, :on_time_range_change,
72
174
  :time_filter_from_toggle, :time_filter_to_toggle, :time_filter_from_input, :time_filter_to_input,
73
- :time_filter_debounce
175
+ :time_filter_debounce, :render_timer, :render_queue, :render_generation, :width_queue,
176
+ :task_suggestion_cache, :visibility_queue, :entry_rows_by_id, :visible_branch_ids,
177
+ :visible_branch_labels, :visible_entry_ids, :empty_label, :project_branch_entry_ids,
178
+ :container_branch_ids, :entry_row_parent_branches, :row_filter_state, :theme_style_queue, :toolbar
74
179
 
75
180
  def build
76
181
  @widget = QWidget.new(parent)
77
182
  widget.object_name = 'entries_panel'
78
183
  panel_layout = build_panel_layout
79
184
  @time_filter_debounce = build_time_filter_debounce_timer
80
- panel_layout.add_widget(build_toolbar(parent_widget: widget))
185
+ @toolbar = build_toolbar(parent_widget: widget)
186
+ panel_layout.add_widget(toolbar)
81
187
  @scroll_area = build_scroll_area
82
188
  bind_scroll_resize
83
189
  panel_layout.add_widget(scroll_area)
84
190
  panel_layout.set_stretch(1, 1)
85
191
  rebuild_host!
192
+ @render_timer = QTimer.new(widget)
193
+ render_timer.interval = 1
194
+ render_timer.connect('timeout') { |_| process_render_slice(render_generation) }
195
+ end
196
+
197
+ def enqueue_render_nodes(nodes, level:, layout:, parent_widget:)
198
+ nodes.reverse_each do |node|
199
+ render_queue << [node, level, layout, parent_widget]
200
+ end
201
+ end
202
+
203
+ def process_render_slice(generation)
204
+ return unless generation == render_generation
205
+
206
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + (RENDER_SLICE_MS / 1000.0)
207
+ content_changed = false
208
+ while queues_pending? && Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline
209
+ if theme_style_queue.any?
210
+ target, stylesheet = theme_style_queue.shift
211
+ target.style_sheet = stylesheet
212
+ elsif visibility_queue.any?
213
+ content_changed = true
214
+ apply_filter_work(visibility_queue.pop)
215
+ elsif width_queue.any?
216
+ content_changed = true
217
+ width_queue.pop.fixed_width = branch_button_width
218
+ else
219
+ content_changed = true
220
+ work = render_queue.pop
221
+ if work.first == :entry_stage
222
+ process_entry_row_stage(work[2], work[1], work[3])
223
+ elsif work.first == :branch_complete
224
+ show_rendered_branch_children(work[1])
225
+ else
226
+ node, level, layout, parent_widget = work
227
+ render_one_node(node, level, layout: layout, parent_widget: parent_widget)
228
+ end
229
+ end
230
+ end
231
+
232
+ if render_queue.empty? && @render_needs_stretch
233
+ content_changed = true
234
+ host_layout.add_stretch(1)
235
+ @render_needs_stretch = false
236
+ end
237
+
238
+ if !queues_pending?
239
+ @rendering = false
240
+ host.update if content_changed
241
+ render_timer.stop if render_timer.is_active
242
+ end
243
+ end
244
+
245
+ def queues_pending?
246
+ render_queue.any? || width_queue.any? || visibility_queue.any? || theme_style_queue.any?
247
+ end
248
+
249
+ def theme_style_targets(stylesheet)
250
+ targets = [[toolbar, @entry_toolbar_stylesheet]]
251
+ unless rendering
252
+ branch_bindings.each do |_node_id, binding|
253
+ targets << [binding.fetch(:button), @entry_branch_stylesheets.fetch("entry_node_#{binding.fetch(:type)}")]
254
+ end
255
+ entry_rows.each do |row|
256
+ targets << [row, @entry_row_stylesheet]
257
+ end
258
+ targets << [empty_label, @entry_empty_stylesheet] if empty_label
259
+ end
260
+ targets.reject { |target, rules| target.nil? || rules.empty? }
261
+ end
262
+
263
+ ENTRY_ROW_STYLE_IDS = %w[
264
+ entry_node_entry_row entry_node_entry_prefix entry_node_entry_time_sep
265
+ entry_node_entry_start entry_node_entry_end entry_node_entry_note
266
+ entry_node_entry_task entry_node_entry_task_editor entry_node_entry_archive
267
+ ].freeze
268
+
269
+ def stylesheet_for_ids(stylesheet, ids)
270
+ id_pattern = ids.map { |id| Regexp.escape("##{id}") }.join('|')
271
+ stylesheet.scan(/([^{}]+)\{([^{}]*)\}/m).filter_map do |selectors, declarations|
272
+ selected = selectors.split(',').map(&:strip).select do |selector|
273
+ selector.match?(/(?:#{id_pattern})(?:\b|(?=[:\s.#>+~\[]))/)
274
+ end
275
+ next if selected.empty?
276
+
277
+ "#{selected.join(",\n")} {#{declarations}}"
278
+ end.join("\n")
279
+ end
280
+
281
+ def apply_entry_widget_styles(target, ids)
282
+ stylesheet = case ids
283
+ when %w[entries_host] then @entry_host_stylesheet
284
+ when ListComponent::ENTRY_ROW_STYLE_IDS then @entry_row_stylesheet
285
+ when %w[entry_node_empty] then @entry_empty_stylesheet
286
+ else @entry_branch_stylesheets&.fetch(ids.first, nil)
287
+ end
288
+ target.style_sheet = stylesheet if stylesheet && !stylesheet.empty?
289
+ end
290
+
291
+ def apply_filter_work(work)
292
+ case work.first
293
+ when :branch then apply_branch_filter(work[1])
294
+ when :entry
295
+ work[2].visible = work[3]
296
+ row_filter_state[work[1]] = work[3]
297
+ when :empty
298
+ empty_label.text = work[1] || 'No entries for filter'
299
+ empty_label.visible = visible_entry_ids.empty?
300
+ end
301
+ end
302
+
303
+ def apply_branch_filter(node_id)
304
+ binding = branch_bindings.fetch(node_id)
305
+ visible = visible_branch_ids.key?(node_id)
306
+ expanded_state = expanded.fetch(node_id, true)
307
+ binding.fetch(:button).visible = visible
308
+ binding.fetch(:children_container).visible = visible && expanded_state
309
+ label = visible_branch_labels[node_id] || binding.fetch(:label)
310
+ binding[:label] = label
311
+ binding[:filter_visible] = visible
312
+ binding.fetch(:button).text = branch_button_text(binding.fetch(:level), label, expanded_state)
313
+ end
314
+
315
+ def render_one_node(node, level, layout:, parent_widget:)
316
+ if node.fetch(:type) == :empty
317
+ apply_entry_widget_styles(empty_label, %w[entry_node_empty])
318
+ return render_empty_node(node)
319
+ end
320
+ return start_entry_row_render(node, level, layout: layout, parent_widget: parent_widget) if node.fetch(:type) == :entry
321
+ return render_node(node, level, layout: layout, parent_widget: parent_widget) unless branch_node?(node)
322
+
323
+ node_id = node.fetch(:id)
324
+ label = node.fetch(:label)
325
+ expanded_state = expanded.fetch(node_id, true)
326
+ button = build_branch_button(node, level, expanded_state, parent_widget: parent_widget)
327
+ apply_entry_widget_styles(button, [object_name_for(node)])
328
+ layout.add_widget(button)
329
+ children_container, children_layout = build_children_container(parent_widget)
330
+ layout.add_widget(children_container)
331
+ register_branch_binding(
332
+ node_id: node_id,
333
+ button: button,
334
+ children_container: children_container,
335
+ label: label,
336
+ level: level
337
+ )
338
+ branch_bindings[node_id][:type] = node.fetch(:type)
339
+ if node.fetch(:type) == :project
340
+ container_branch_ids[children_container.handle.address] = node_id
341
+ project_branch_entry_ids[node_id] = []
342
+ end
343
+ children_container.visible = false
344
+ button.text = branch_button_text(level, label, expanded_state)
345
+ button_visible = visible_branch_ids.nil? || visible_branch_ids.key?(node_id)
346
+ button.visible = button_visible
347
+ branch_bindings[node_id][:filter_visible] = button_visible
348
+ if expanded_state
349
+ render_queue << [:branch_complete, node_id]
350
+ enqueue_render_nodes(node.fetch(:children), level: level + 1, layout: children_layout,
351
+ parent_widget: children_container)
352
+ end
353
+ end
354
+
355
+ def show_rendered_branch_children(node_id)
356
+ binding = branch_bindings.fetch(node_id)
357
+ visible = visible_branch_ids.nil? || visible_branch_ids.key?(node_id)
358
+ expanded_state = expanded.fetch(node_id, true)
359
+ binding.fetch(:button).visible = visible
360
+ binding.fetch(:children_container).visible = visible && expanded_state
361
+ label = visible_branch_labels[node_id] || binding.fetch(:label)
362
+ binding[:label] = label
363
+ binding[:filter_visible] = visible
364
+ binding.fetch(:button).text = branch_button_text(binding.fetch(:level), label, expanded_state)
365
+ end
366
+
367
+ def render_empty_node(node)
368
+ empty_label.text = node.fetch(:label)
369
+ empty_label.visible = true
86
370
  end
87
371
 
88
372
  def branch_button_width
@@ -91,10 +375,12 @@ module QTimetrap
91
375
  end
92
376
 
93
377
  def adjust_node_widths
94
- width = branch_button_width
95
- branch_bindings.each_value { |binding| binding.fetch(:button).fixed_width = width }
96
- leaf_labels.each { |label| label.fixed_width = width }
97
- entry_rows.each { |row| row.fixed_width = width }
378
+ @width_queue = [
379
+ *branch_bindings.values.map { |binding| binding.fetch(:button) },
380
+ *leaf_labels,
381
+ *entry_rows
382
+ ]
383
+ render_timer.start if width_queue.any? && !render_timer.is_active
98
384
  end
99
385
 
100
386
  def build_panel_layout
@@ -18,7 +18,10 @@ module QTimetrap
18
18
  end
19
19
 
20
20
  def rebuild_host!
21
+ previous_host = scroll_area.take_widget
22
+ previous_host.delete_later if previous_host
21
23
  @host = build_host
24
+ apply_entry_widget_styles(host, %w[entries_host])
22
25
  @host_layout = build_host_layout
23
26
  scroll_area.widget = host
24
27
  end
@@ -19,6 +19,7 @@ module QTimetrap
19
19
  @active_task_container_key = nil
20
20
  @leaf_labels = []
21
21
  @entry_rows = []
22
+ @theme_style_queue = []
22
23
  @rendering = false
23
24
  end
24
25
 
@@ -27,6 +28,7 @@ module QTimetrap
27
28
  @task_display_inputs = {}
28
29
  @task_editors = {}
29
30
  @task_editor_line_edits = {}
31
+ @task_editor_configs = {}
30
32
  end
31
33
 
32
34
  def initialize_time_filter_state!
@@ -68,10 +70,23 @@ module QTimetrap
68
70
  container_key = task_container_key(task_container)
69
71
  task_containers[container_key] = task_container
70
72
  task_display_inputs[container_key] = display_input
73
+ register_task_editor_widgets(task_container, editor, line_edit) if editor
74
+ end
75
+
76
+ def register_task_editor_config(task_container, node:, task_name:)
77
+ task_editor_configs[task_container_key(task_container)] = { node: node, task_name: task_name }
78
+ end
79
+
80
+ def register_task_editor_widgets(task_container, editor, line_edit)
81
+ container_key = task_container_key(task_container)
71
82
  task_editors[container_key] = editor
72
83
  task_editor_line_edits[task_editor_key(editor)] = line_edit
73
84
  end
74
85
 
86
+ def task_editor_config_for(task_container)
87
+ task_editor_configs[task_container_key(task_container)]
88
+ end
89
+
75
90
  def task_container_key(task_container)
76
91
  task_container.handle.address
77
92
  end
@@ -105,11 +120,13 @@ module QTimetrap
105
120
  end
106
121
 
107
122
  def task_editor_line_edit_for(editor)
123
+ return unless editor
124
+
108
125
  task_editor_line_edits[task_editor_key(editor)]
109
126
  end
110
127
 
111
128
  attr_reader :task_containers, :task_display_inputs, :task_editors, :task_editor_line_edits,
112
- :active_task_container_key
129
+ :active_task_container_key, :task_editor_configs
113
130
  end
114
131
  end
115
132
  end
@@ -34,6 +34,17 @@ module QTimetrap
34
34
  archive_toggle_button.checked = archive_mode
35
35
  end
36
36
 
37
+ def update_tasks(tasks:, selected_project:, selected_task:)
38
+ render_tasks(tasks: tasks, selected_project: selected_project, selected_task: selected_task)
39
+ end
40
+
41
+ def theme_style_targets(stylesheet)
42
+ widgets = [@logo_text, @heading, tasks_heading, archive_toggle_button]
43
+ widgets.concat(buttons.map { |slot| slot.fetch(:view) })
44
+ widgets.concat(task_buttons)
45
+ widgets.compact.map { |target| [target, stylesheet] }
46
+ end
47
+
37
48
  private
38
49
 
39
50
  attr_reader :parent, :on_project_selected, :on_task_selected, :buttons, :buttons_layout, :task_buttons,
@@ -30,7 +30,7 @@ module QTimetrap
30
30
  end
31
31
 
32
32
  def build_logo_text(parent_widget)
33
- QLabel.new(parent_widget).tap do |label|
33
+ @logo_text = QLabel.new(parent_widget).tap do |label|
34
34
  label.object_name = 'sidebar_logo_text'
35
35
  label.alignment = Qt::AlignCenter
36
36
  label.text = 'QTimetrap'
@@ -42,7 +42,7 @@ module QTimetrap
42
42
  end
43
43
 
44
44
  def build_heading
45
- QLabel.new(widget).tap do |label|
45
+ @heading = QLabel.new(widget).tap do |label|
46
46
  label.object_name = 'sidebar_heading'
47
47
  label.alignment = Qt::AlignCenter
48
48
  label.text = 'PROJECTS'
@@ -40,6 +40,10 @@ module QTimetrap
40
40
  project_input.read_only = running
41
41
  end
42
42
 
43
+ def theme_style_targets(stylesheet)
44
+ theme_widgets.compact.map { |target| [target, stylesheet] }
45
+ end
46
+
43
47
  private
44
48
 
45
49
  attr_reader :summary_label, :theme_button, :start_button, :stop_button
@@ -69,9 +73,16 @@ module QTimetrap
69
73
  @theme_button = ui_map.fetch(:theme_button)
70
74
  @start_button = ui_map.fetch(:start_button)
71
75
  @stop_button = ui_map.fetch(:stop_button)
76
+ @theme_widgets = [
77
+ ui_map.fetch(:topbar), ui_map.fetch(:tracker_row), ui_map.fetch(:actions_row),
78
+ ui_map.fetch(:title_label), clock_label, timer_label, task_input, project_input,
79
+ summary_label, theme_button, start_button, stop_button, ui_map.fetch(:refresh_button)
80
+ ]
72
81
  configure_timer_font_fit!
73
82
  end
74
83
 
84
+ attr_reader :theme_widgets
85
+
75
86
  def configure_timer_font_fit!
76
87
  apply_timer_font_fit(TIMER_FIT_SAMPLE)
77
88
  end
@@ -15,11 +15,13 @@ module QTimetrap
15
15
  def build
16
16
  @widget = QWidget.new(parent)
17
17
  root = build_root_layout
18
- root.add_widget(build_topbar)
18
+ @topbar = build_topbar
19
+ root.add_widget(@topbar)
19
20
  start_button, stop_button = build_tracker_row(root)
20
21
  refresh_button = build_actions_row(root)
22
+ @refresh_button = refresh_button
21
23
  connect_actions(start_button, stop_button, refresh_button)
22
- ui_payload
24
+ ui_payload.merge(theme_ui_payload)
23
25
  end
24
26
 
25
27
  private
@@ -35,7 +37,7 @@ module QTimetrap
35
37
  end
36
38
 
37
39
  def build_tracker_row(root_layout)
38
- row = QWidget.new(widget)
40
+ row = @tracker_row = QWidget.new(widget)
39
41
  row.object_name = 'tracker_row'
40
42
  layout = QHBoxLayout.new(row)
41
43
  layout.set_contents_margins(14, 12, 14, 12)
@@ -46,7 +48,7 @@ module QTimetrap
46
48
  end
47
49
 
48
50
  def build_actions_row(root_layout)
49
- row = QWidget.new(widget)
51
+ row = @actions_row = QWidget.new(widget)
50
52
  layout = QHBoxLayout.new(row)
51
53
  layout.set_contents_margins(0, 0, 0, 0)
52
54
  layout.spacing = 8
@@ -82,6 +84,16 @@ module QTimetrap
82
84
  connect_project_input
83
85
  end
84
86
 
87
+ def theme_ui_payload
88
+ {
89
+ topbar: @topbar,
90
+ tracker_row: @tracker_row,
91
+ actions_row: @actions_row,
92
+ title_label: @title_label,
93
+ refresh_button: @refresh_button
94
+ }
95
+ end
96
+
85
97
  def connect_start_stop(start_button, stop_button)
86
98
  start_button.connect('clicked') do |_|
87
99
  callbacks.fetch(:on_start).call(@task_input.text.to_s, @project_input.text.to_s)
@@ -26,7 +26,8 @@ module QTimetrap
26
26
  def configure_topbar_layout(layout, topbar)
27
27
  layout.set_contents_margins(16, 8, 16, 8)
28
28
  layout.spacing = 8
29
- layout.add_widget(build_label(topbar, 'title_label', random_title_slogan))
29
+ @title_label = build_label(topbar, 'title_label', random_title_slogan)
30
+ layout.add_widget(@title_label)
30
31
  layout.add_stretch(1)
31
32
  @clock_label = build_label(topbar, 'clock_label', nil, width: 220)
32
33
  @clock_label.alignment = Qt::AlignCenter
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'fileutils'
4
+ require 'set'
4
5
  require 'yaml'
5
6
 
6
7
  module QTimetrap
@@ -12,7 +13,7 @@ module QTimetrap
12
13
  end
13
14
 
14
15
  def archived_ids
15
- read_ids
16
+ read_ids.to_a.sort
16
17
  end
17
18
 
18
19
  def archived?(entry_id)
@@ -47,19 +48,23 @@ module QTimetrap
47
48
  attr_reader :path
48
49
 
49
50
  def read_ids
50
- return [] unless File.exist?(path)
51
+ return @ids_cache if @ids_cache
52
+ return @ids_cache = Set.new unless File.exist?(path)
51
53
 
52
54
  payload = YAML.safe_load_file(path, permitted_classes: [], aliases: false)
53
- return [] unless payload.is_a?(Hash)
55
+ return @ids_cache = Set.new unless payload.is_a?(Hash)
54
56
 
55
- Array(payload['archived_entry_ids']).filter_map { |value| Integer(value) }.uniq.sort
57
+ values = Array(payload['archived_entry_ids']).filter_map { |value| Integer(value) }.uniq
58
+ @ids_cache = Set.new(values)
56
59
  rescue Psych::SyntaxError, ArgumentError, TypeError
57
- []
60
+ @ids_cache = Set.new
58
61
  end
59
62
 
60
63
  def write_ids(ids)
61
64
  FileUtils.mkdir_p(File.dirname(path))
62
- File.write(path, { 'archived_entry_ids' => ids.uniq.sort }.to_yaml)
65
+ normalized_ids = ids.uniq.sort
66
+ File.write(path, { 'archived_entry_ids' => normalized_ids }.to_yaml)
67
+ @ids_cache = Set.new(normalized_ids)
63
68
  end
64
69
 
65
70
  def default_path
@@ -190,25 +190,13 @@ QLineEdit#entry_node_entry_note:focus {
190
190
  padding-left: 6px;
191
191
  }
192
192
 
193
- QLineEdit#entry_node_entry_task {
193
+ QLabel#entry_node_entry_task {
194
194
  color: #94a3b8;
195
195
  font-size: 12px;
196
196
  background: transparent;
197
- border: 0px;
198
197
  padding-left: 2px;
199
198
  }
200
199
 
201
- QLineEdit#entry_node_entry_task:read-only {
202
- background: transparent;
203
- border: 0px;
204
- }
205
-
206
- QLineEdit#entry_node_entry_task:focus {
207
- background-color: #111827;
208
- border: 1px solid #334155;
209
- border-radius: 6px;
210
- }
211
-
212
200
  QComboBox#entry_node_entry_task_editor {
213
201
  color: #94a3b8;
214
202
  font-size: 12px;
@@ -190,25 +190,13 @@ QLineEdit#entry_node_entry_note:focus {
190
190
  padding-left: 6px;
191
191
  }
192
192
 
193
- QLineEdit#entry_node_entry_task {
193
+ QLabel#entry_node_entry_task {
194
194
  color: #334155;
195
195
  font-size: 12px;
196
196
  background: transparent;
197
- border: 0px;
198
197
  padding-left: 2px;
199
198
  }
200
199
 
201
- QLineEdit#entry_node_entry_task:read-only {
202
- background: transparent;
203
- border: 0px;
204
- }
205
-
206
- QLineEdit#entry_node_entry_task:focus {
207
- background-color: #ffffff;
208
- border: 1px solid #d8e0ea;
209
- border-radius: 6px;
210
- }
211
-
212
200
  QComboBox#entry_node_entry_task_editor {
213
201
  color: #334155;
214
202
  font-size: 12px;
@@ -93,6 +93,10 @@ module QTimetrap
93
93
  EntryNodesBuilder.new(entries: filtered_entries, selected_project: selected_project).build
94
94
  end
95
95
 
96
+ def all_entry_nodes
97
+ EntryNodesBuilder.new(entries: entries, selected_project: '* ALL').build
98
+ end
99
+
96
100
  private
97
101
 
98
102
  attr_reader :gateway, :archived_entries_store
@@ -46,7 +46,6 @@ module QTimetrap
46
46
  @window = build_base_window
47
47
  restore_window_geometry
48
48
  set_window_icon
49
- window.style_sheet = theme.application_stylesheet
50
49
  ui = MainWindowLayoutBuilder.new(
51
50
  window: window,
52
51
  callbacks: layout_callbacks
@@ -54,6 +53,7 @@ module QTimetrap
54
53
  @sidebar = ui.fetch(:sidebar)
55
54
  @controls = ui.fetch(:controls)
56
55
  @entries = ui.fetch(:entries)
56
+ apply_theme_styles
57
57
  end
58
58
 
59
59
  def connect_heartbeat
@@ -56,7 +56,13 @@ module QTimetrap
56
56
  update_current_fields = !view_model.running_current_sheet?
57
57
  view_model.select_projects(projects, primary_project: project, sync_current_fields: update_current_fields)
58
58
  view_model.current_project_name = project if update_current_fields && project != '* ALL'
59
- render!
59
+ sidebar.update_tasks(
60
+ tasks: view_model.task_names_for_selected_project,
61
+ selected_project: view_model.selected_project,
62
+ selected_task: view_model.selected_tasks.first
63
+ )
64
+ controls.update_summary(view_model.summary_line)
65
+ entries.filter_to(view_model.entry_nodes)
60
66
  return unless update_current_fields
61
67
 
62
68
  controls.update_task_input(view_model.current_sheet_input)
@@ -69,8 +75,8 @@ module QTimetrap
69
75
  view_model.current_task_input = task.to_s
70
76
  controls.update_task_input(view_model.current_sheet_input)
71
77
  end
72
- render_controls(sync_sheet: false)
73
- entries.render(view_model.entry_nodes)
78
+ controls.update_summary(view_model.summary_line)
79
+ entries.filter_to(view_model.entry_nodes)
74
80
  end
75
81
 
76
82
  def handle_project_input(project_name)
@@ -81,12 +87,12 @@ module QTimetrap
81
87
 
82
88
  def handle_time_range_changed(from_at, to_at)
83
89
  view_model.update_time_range_filter(from_at: from_at, to_at: to_at)
84
- render_controls(sync_sheet: false)
90
+ controls.update_summary(view_model.summary_line)
85
91
  entries.update_time_range_inputs(
86
92
  from_at: view_model.time_filter_from_at,
87
93
  to_at: view_model.time_filter_to_at
88
94
  )
89
- entries.render(view_model.entry_nodes)
95
+ entries.filter_to(view_model.entry_nodes)
90
96
  rescue StandardError => e
91
97
  warn("[qtimetrap] update time-range failed: #{e.class}: #{e.message}")
92
98
  end
@@ -114,7 +120,7 @@ module QTimetrap
114
120
  def switch_theme!
115
121
  @theme = theme.with_name(next_theme_name)
116
122
  apply_theme
117
- render!
123
+ controls.update_theme_label(theme.name)
118
124
  rescue StandardError => e
119
125
  warn("[qtimetrap] save theme failed: #{e.class}: #{e.message}")
120
126
  end
@@ -8,7 +8,13 @@ module QTimetrap
8
8
 
9
9
  def handle_archive_mode_toggled(enabled)
10
10
  view_model.archive_mode = enabled
11
- render!(sync_sheet: false)
11
+ sidebar.update_tasks(
12
+ tasks: view_model.task_names_for_selected_project,
13
+ selected_project: view_model.selected_project,
14
+ selected_task: view_model.selected_tasks.first
15
+ )
16
+ controls.update_summary(view_model.summary_line)
17
+ entries.filter_to(view_model.entry_nodes)
12
18
  end
13
19
 
14
20
  def handle_entry_archived(entry_id)
@@ -30,7 +30,8 @@ module QTimetrap
30
30
  from_at: view_model.time_filter_from_at,
31
31
  to_at: view_model.time_filter_to_at
32
32
  )
33
- entries.render(view_model.entry_nodes)
33
+ entries.render(view_model.all_entry_nodes)
34
+ entries.filter_to(view_model.entry_nodes)
34
35
  end
35
36
 
36
37
  def render_controls(sync_sheet:)
@@ -37,9 +37,37 @@ module QTimetrap
37
37
 
38
38
  def apply_theme
39
39
  Application.configuration.theme_name = theme.name
40
- window.style_sheet = theme.application_stylesheet
40
+ apply_theme_styles(update_window_background: false, incremental: true)
41
41
  settings_store.write_theme_name(theme.name)
42
42
  end
43
+
44
+ def apply_theme_styles(update_window_background: true, incremental: false)
45
+ window.style_sheet = theme.stylesheet('application') if update_window_background
46
+ sidebar_stylesheet = theme.stylesheet('project_sidebar')
47
+ controls_stylesheet = theme.stylesheet('tracker_controls')
48
+ entries_stylesheet = theme.stylesheet('entries_list')
49
+ if incremental
50
+ sidebar.widget.style_sheet = stylesheet_rule(sidebar_stylesheet, 'QWidget#sidebar_panel')
51
+ entries.apply_theme_stylesheet(
52
+ entries_stylesheet,
53
+ external_targets: sidebar.theme_style_targets(sidebar_stylesheet) +
54
+ controls.theme_style_targets(controls_stylesheet),
55
+ defer: true
56
+ )
57
+ else
58
+ sidebar.widget.style_sheet = sidebar_stylesheet
59
+ controls.widget.style_sheet = controls_stylesheet
60
+ entries.apply_theme_stylesheet(entries_stylesheet)
61
+ end
62
+ end
63
+
64
+ def stylesheet_rule(stylesheet, selector)
65
+ stylesheet.scan(/([^{}]+)\{([^{}]*)\}/m).find do |selectors, _declarations|
66
+ selectors.split(',').map(&:strip).include?(selector)
67
+ end&.then do |selectors, declarations|
68
+ "#{selectors.strip} {#{declarations}}"
69
+ end.to_s
70
+ end
43
71
  end
44
72
  end
45
73
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module QTimetrap
4
- VERSION = '0.1.2'
4
+ VERSION = '0.1.3'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: qtimetrap
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.1.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Maksim Veynberg
@@ -60,6 +60,7 @@ files:
60
60
  - app/assets/icons/qtimetrap-icon-512.png
61
61
  - app/assets/icons/qtimetrap-icon.svg
62
62
  - app/components/entries/branch_hierarchy_helpers.rb
63
+ - app/components/entries/entry_row_render_helpers.rb
63
64
  - app/components/entries/leaf_archive_helpers.rb
64
65
  - app/components/entries/leaf_note_helpers.rb
65
66
  - app/components/entries/leaf_task_display_helpers.rb
@@ -206,7 +207,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
206
207
  - !ruby/object:Gem::Version
207
208
  version: '0'
208
209
  requirements: []
209
- rubygems_version: 3.6.7
210
+ rubygems_version: 4.0.10
210
211
  specification_version: 4
211
212
  summary: Desktop Timetrap UI on Qt
212
213
  test_files: []