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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 1c354f5a65349384bd932fceb8f62cb7b43927c789b67bcd57a5c064baf6754f
4
+ data.tar.gz: a0b067c516cfc16874afcd1f98088e8e8dbf023c079bc26cdb390c2e6e0c6b58
5
+ SHA512:
6
+ metadata.gz: b8a21804d830a6b48bedd2bcd3bf53b7ddc00db6d9b38174a3c6ff0a7fc63dc7b41e61ba2ac301a4aeef5c9b9d432c096bd1c1bce614ada07fb7c50a19e4d840
7
+ data.tar.gz: ba9ba9ff36f90346913699562b13c0842281e5c27bab6e22f5e326638b9c49bfceca7f51039345d84a994831065c1a73bfe8891ebed147ddfea2abb0a534a11d
data/LICENSE ADDED
@@ -0,0 +1,25 @@
1
+ BSD 2-Clause License
2
+
3
+ Copyright (c) 2026, Maksim Veynberg
4
+ All rights reserved.
5
+
6
+ Redistribution and use in source and binary forms, with or without
7
+ modification, are permitted provided that the following conditions are met:
8
+
9
+ 1. Redistributions of source code must retain the above copyright notice, this
10
+ list of conditions and the following disclaimer.
11
+
12
+ 2. Redistributions in binary form must reproduce the above copyright notice,
13
+ this list of conditions and the following disclaimer in the documentation
14
+ and/or other materials provided with the distribution.
15
+
16
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
17
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
18
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
19
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
20
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
21
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
22
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
23
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
24
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
25
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
data/README.md ADDED
@@ -0,0 +1,92 @@
1
+ # QTimetrap
2
+
3
+ Desktop UI for [Timetrap](https://github.com/samg/timetrap), built with Ruby + Qt.
4
+
5
+ QTimetrap follows an MVVM-style architecture with `Zeitwerk` autoloading and a Rails-like project structure.
6
+
7
+ ![QTimetrap screenshot](docs/screenshots/main.png)
8
+
9
+ ## Features
10
+
11
+ - Expandable entries tree: `week -> day -> project/task -> time entry`.
12
+ - Start/stop tracking from UI (single button based on running state).
13
+ - Edit entry `note`, `start`, and `end` directly in the list.
14
+ - Project/task sidebar with multi-select task filtering.
15
+ - Date-time interval filtering (`FROM`/`TO`) with live apply.
16
+ - Archive mode (soft-hide entries via local archive store, no destructive Timetrap delete).
17
+ - Theme switch (`light`/`dark`) with persistent selection.
18
+
19
+ ## Requirements
20
+
21
+ - Ruby `>= 3.2`
22
+ - Qt bridge gem: `qt >= 0.1.4`
23
+ - `timetrap` CLI available as `t` (or configured via env)
24
+
25
+ ## Install
26
+
27
+ ### RubyGems
28
+
29
+ ```bash
30
+ gem install qtimetrap timetrap
31
+ qtimetrap
32
+ ```
33
+
34
+ Note: `qtimetrap` depends on the `qt` gem with native extensions.
35
+ Install required system libraries first:
36
+ <https://github.com/CyJimmy264/qt?tab=readme-ov-file#system-requirements>.
37
+
38
+ ### Fedora COPR
39
+
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
44
+ gem install timetrap
45
+ qtimetrap
46
+ ```
47
+
48
+ ## Run
49
+
50
+ ```bash
51
+ bundle install
52
+ bundle exec bin/qtimetrap
53
+ ```
54
+
55
+ If `qt` is already installed in your current `rbenv` shell, you can also run:
56
+
57
+ ```bash
58
+ bin/qtimetrap
59
+ ```
60
+
61
+ ## Configuration
62
+
63
+ - `QTIMETRAP_ENV`: app environment (`development` by default)
64
+ - `TIMETRAP_BIN`: timetrap CLI command (`t` by default)
65
+ - `QTIMETRAP_THEME`: initial theme (`light` by default)
66
+ - `QTIMETRAP_RELOAD=1`: enable Zeitwerk reloading in development
67
+
68
+ Persisted settings:
69
+
70
+ - Theme: `~/.config/qtimetrap/config.yml`
71
+ - Archived entry ids: `~/.local/share/qtimetrap/archived_entries.yml`
72
+
73
+ (`$XDG_CONFIG_HOME` / `$XDG_DATA_HOME` are respected.)
74
+
75
+ ## Development
76
+
77
+ ```bash
78
+ rspec
79
+ gem build qtimetrap.gemspec
80
+ ```
81
+
82
+ Main directories:
83
+
84
+ - `app/models`, `app/services`, `app/view_models`
85
+ - `app/views`, `app/components`
86
+ - `app/styles/themes/{light,dark}/*.qss`
87
+ - `config/`, `lib/`, `spec/`
88
+
89
+ ## Packaging
90
+
91
+ - Fedora/COPR assets: `packaging/rpm`
92
+ - Debian/Launchpad assets: `packaging/deb`
data/Rakefile ADDED
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
4
+ require 'fileutils'
5
+
6
+ # Installs QTimetrap desktop entry and icon theme assets under ~/.local/share.
7
+ module DesktopInstall
8
+ module_function
9
+
10
+ def root
11
+ File.expand_path(__dir__)
12
+ end
13
+
14
+ def home
15
+ Dir.home
16
+ end
17
+
18
+ def applications_dir
19
+ File.join(home, '.local', 'share', 'applications')
20
+ end
21
+
22
+ def icons_root
23
+ File.join(home, '.local', 'share', 'icons', 'hicolor')
24
+ end
25
+
26
+ def desktop_file
27
+ File.join(applications_dir, 'qtimetrap.desktop')
28
+ end
29
+
30
+ def icon_svg_src
31
+ File.join(root, 'app', 'assets', 'icons', 'qtimetrap-icon.svg')
32
+ end
33
+
34
+ def icon_png_sizes
35
+ {
36
+ '128x128' => File.join(root, 'app', 'assets', 'icons', 'qtimetrap-icon-128.png'),
37
+ '256x256' => File.join(root, 'app', 'assets', 'icons', 'qtimetrap-icon-256.png'),
38
+ '512x512' => File.join(root, 'app', 'assets', 'icons', 'qtimetrap-icon-512.png')
39
+ }
40
+ end
41
+
42
+ def exec_path
43
+ rbenv_shim = File.join(home, '.rbenv', 'shims', 'qtimetrap')
44
+ ENV.fetch('QTIMETRAP_DESKTOP_EXEC', File.executable?(rbenv_shim) ? rbenv_shim : 'qtimetrap')
45
+ end
46
+
47
+ def install!
48
+ FileUtils.mkdir_p(applications_dir)
49
+ install_icons!
50
+ File.write(desktop_file, desktop_entry)
51
+ validate_desktop_file!
52
+ refresh_icon_cache!
53
+ puts "Installed desktop entry: #{desktop_file}"
54
+ end
55
+
56
+ def install_icons!
57
+ FileUtils.mkdir_p(File.join(icons_root, 'scalable', 'apps'))
58
+ FileUtils.cp(icon_svg_src, File.join(icons_root, 'scalable', 'apps', 'qtimetrap.svg'))
59
+ icon_png_sizes.each do |size, src|
60
+ next unless File.file?(src)
61
+
62
+ target_dir = File.join(icons_root, size, 'apps')
63
+ FileUtils.mkdir_p(target_dir)
64
+ FileUtils.cp(src, File.join(target_dir, 'qtimetrap.png'))
65
+ end
66
+ end
67
+
68
+ def desktop_entry
69
+ <<~DESKTOP
70
+ [Desktop Entry]
71
+ Type=Application
72
+ Version=1.0
73
+ Name=QTimetrap
74
+ Comment=Desktop Timetrap UI on Qt
75
+ Exec=#{exec_path}
76
+ Icon=qtimetrap
77
+ Terminal=false
78
+ Categories=Office;
79
+ StartupNotify=true
80
+ StartupWMClass=qtimetrap
81
+ X-KDE-StartupNotify=true
82
+ DESKTOP
83
+ end
84
+
85
+ def validate_desktop_file!
86
+ return unless system('sh', '-lc', 'command -v desktop-file-validate >/dev/null 2>&1')
87
+ return if system('desktop-file-validate', desktop_file)
88
+
89
+ abort("desktop-file-validate failed for #{desktop_file}")
90
+ end
91
+
92
+ def refresh_icon_cache!
93
+ return unless system('sh', '-lc', 'command -v gtk-update-icon-cache >/dev/null 2>&1')
94
+ return unless File.file?(File.join(icons_root, 'index.theme'))
95
+
96
+ system('gtk-update-icon-cache', '-q', '-t', icons_root)
97
+ end
98
+ end
99
+
100
+ namespace :desktop do
101
+ desc 'Install QTimetrap desktop entry into ~/.local/share/applications'
102
+ task :install do
103
+ DesktopInstall.install!
104
+ end
105
+ end
@@ -0,0 +1,123 @@
1
+ <?xml version="1.0" encoding="UTF-8" standalone="no"?>
2
+ <svg
3
+ width="512"
4
+ height="512"
5
+ viewBox="0 0 512 512"
6
+ fill="none"
7
+ version="1.1"
8
+ id="svg8"
9
+ sodipodi:docname="qtimetrap-icon.svg"
10
+ inkscape:version="1.4.3 (0d15f75042, 2025-12-25)"
11
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
12
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
13
+ xmlns="http://www.w3.org/2000/svg"
14
+ xmlns:svg="http://www.w3.org/2000/svg">
15
+ <sodipodi:namedview
16
+ id="namedview8"
17
+ pagecolor="#ffffff"
18
+ bordercolor="#000000"
19
+ borderopacity="0.25"
20
+ inkscape:showpageshadow="2"
21
+ inkscape:pageopacity="0.0"
22
+ inkscape:pagecheckerboard="0"
23
+ inkscape:deskcolor="#d1d1d1"
24
+ inkscape:zoom="0.21728516"
25
+ inkscape:cx="-775.47865"
26
+ inkscape:cy="1320.8449"
27
+ inkscape:window-width="1920"
28
+ inkscape:window-height="1136"
29
+ inkscape:window-x="0"
30
+ inkscape:window-y="0"
31
+ inkscape:window-maximized="1"
32
+ inkscape:current-layer="svg8" />
33
+ <defs
34
+ id="defs4">
35
+ <linearGradient
36
+ id="bg"
37
+ x1="64"
38
+ y1="48"
39
+ x2="448"
40
+ y2="464"
41
+ gradientUnits="userSpaceOnUse">
42
+ <stop
43
+ stop-color="#0EA5E9"
44
+ id="stop1" />
45
+ <stop
46
+ offset="1"
47
+ stop-color="#2563EB"
48
+ id="stop2" />
49
+ </linearGradient>
50
+ <linearGradient
51
+ id="ring"
52
+ x1="132"
53
+ y1="118"
54
+ x2="386"
55
+ y2="394"
56
+ gradientUnits="userSpaceOnUse">
57
+ <stop
58
+ stop-color="#E0F2FE"
59
+ id="stop3" />
60
+ <stop
61
+ offset="1"
62
+ stop-color="#BAE6FD"
63
+ id="stop4" />
64
+ </linearGradient>
65
+ </defs>
66
+ <rect
67
+ x="40"
68
+ y="40"
69
+ width="432"
70
+ height="432"
71
+ rx="108"
72
+ fill="url(#bg)"
73
+ id="rect4" />
74
+ <!-- Q ring -->
75
+ <circle
76
+ cx="252"
77
+ cy="248"
78
+ r="128"
79
+ stroke="url(#ring)"
80
+ stroke-width="36"
81
+ id="circle4" />
82
+ <!-- Q tail -->
83
+ <path
84
+ d="m 333.45169,346.65618 60,58"
85
+ stroke="#e0f2fe"
86
+ stroke-width="34"
87
+ stroke-linecap="round"
88
+ id="path4" />
89
+ <!-- clock hands -->
90
+ <path
91
+ d="m 256.08439,253.93795 59.98108,-59.12082"
92
+ stroke="#e0f2fe"
93
+ stroke-width="22"
94
+ stroke-linecap="round"
95
+ id="path5" />
96
+ <path
97
+ d="m 245.67191,250.30113 -47,-30"
98
+ stroke="#e0f2fe"
99
+ stroke-width="22"
100
+ stroke-linecap="round"
101
+ id="path6" />
102
+ <circle
103
+ cx="253.72585"
104
+ cy="252.60225"
105
+ r="14"
106
+ fill="#e0f2fe"
107
+ id="circle6" />
108
+ <!-- extra-thick check with dark-green outline -->
109
+ <path
110
+ d="m 53.12809,385.47416 74,68 106,-114"
111
+ stroke="#14532d"
112
+ stroke-width="88"
113
+ stroke-linecap="round"
114
+ stroke-linejoin="round"
115
+ id="path7" />
116
+ <path
117
+ d="m 53.12809,385.47416 74,68 106,-114"
118
+ stroke="#22c55e"
119
+ stroke-width="60"
120
+ stroke-linecap="round"
121
+ stroke-linejoin="round"
122
+ id="path8" />
123
+ </svg>
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QTimetrap
4
+ module Entries
5
+ # Hierarchical branch container/show-hide helpers for entries tree UI.
6
+ module BranchHierarchyHelpers
7
+ private
8
+
9
+ def build_children_container(parent_widget)
10
+ container = QWidget.new(parent_widget)
11
+ container.set_object_name('entry_node_children')
12
+ layout = QVBoxLayout.new(container)
13
+ layout.set_contents_margins(0, 0, 0, 0)
14
+ layout.set_spacing(2)
15
+ [container, layout]
16
+ end
17
+
18
+ def register_branch_binding(node_id:, button:, children_container:, label:, level:)
19
+ branch_bindings[node_id] = {
20
+ button: button,
21
+ children_container: children_container,
22
+ label: label,
23
+ level: level
24
+ }
25
+ end
26
+
27
+ def apply_all_branch_states
28
+ branch_bindings.each_key { |node_id| apply_branch_state(node_id, expanded.fetch(node_id, true)) }
29
+ end
30
+
31
+ def apply_branch_state(node_id, visible)
32
+ binding = branch_bindings[node_id]
33
+ return unless binding
34
+
35
+ binding.fetch(:children_container).set_visible(visible)
36
+ text = branch_button_text(binding.fetch(:level), binding.fetch(:label), visible)
37
+ binding.fetch(:button).set_text(text)
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QTimetrap
4
+ module Entries
5
+ # Entry-leaf helpers for archive action button.
6
+ module LeafArchiveHelpers
7
+ private
8
+
9
+ def build_entry_archive_button(row, node)
10
+ QPushButton.new(row).tap do |button|
11
+ button.set_object_name('entry_node_entry_archive')
12
+ button.set_text('🗃')
13
+ button.set_tool_tip('Toggle archive state')
14
+ button.set_focus_policy(Qt::NoFocus)
15
+ button.set_fixed_width(28)
16
+ button.set_fixed_height(24)
17
+ entry_id = resolve_entry_id(node)
18
+ button.connect('clicked') { |_| on_entry_archive&.call(entry_id) }
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QTimetrap
4
+ module Entries
5
+ # Entry-leaf row builders for editable note nodes.
6
+ module LeafNoteHelpers
7
+ private
8
+
9
+ def render_entry_leaf_node(node, level, layout:, parent_widget:)
10
+ row = build_entry_row(parent_widget)
11
+ row_layout = build_entry_row_layout(row)
12
+ start_input, end_input = add_entry_time_widgets(row_layout, row, node)
13
+ add_entry_row_widgets(row_layout, row, node, level)
14
+ bind_entry_time_input_events(start_input, end_input, resolve_entry_id(node))
15
+ apply_entry_row_stretch(row_layout)
16
+ entry_rows << row
17
+ layout.add_widget(row)
18
+ end
19
+
20
+ def add_entry_row_widgets(row_layout, row, node, level)
21
+ row_layout.add_widget(build_entry_prefix_label(row, node, level))
22
+ row_layout.add_widget(build_entry_task_input(row, node))
23
+ row_layout.add_widget(build_entry_note_input(row, node))
24
+ row_layout.add_widget(build_entry_archive_button(row, node))
25
+ end
26
+
27
+ def apply_entry_row_stretch(row_layout)
28
+ row_layout.set_stretch(2, 1)
29
+ row_layout.set_stretch(3, 2)
30
+ end
31
+
32
+ def build_entry_row(parent_widget)
33
+ QWidget.new(parent_widget).tap do |row|
34
+ row.set_object_name('entry_node_entry_row')
35
+ row.set_fixed_width(branch_button_width)
36
+ row.set_fixed_height(32)
37
+ end
38
+ end
39
+
40
+ def build_entry_row_layout(row)
41
+ QHBoxLayout.new(row).tap do |layout|
42
+ layout.set_contents_margins(8, 0, 8, 0)
43
+ layout.set_spacing(6)
44
+ end
45
+ end
46
+
47
+ def build_entry_prefix_label(row, node, level)
48
+ QLabel.new(row).tap do |prefix_label|
49
+ prefix_label.set_object_name('entry_node_entry_prefix')
50
+ prefix_label.set_text("#{indent(level)}#{node.fetch(:prefix, node.fetch(:label))}")
51
+ end
52
+ end
53
+
54
+ def build_entry_note_input(row, node)
55
+ QLineEdit.new(row).tap do |note_input|
56
+ note_input.set_object_name('entry_node_entry_note')
57
+ note_input.text = node.fetch(:note, '')
58
+ note_input.set_placeholder_text('(no note)')
59
+ note_input.set_read_only(true)
60
+ bind_entry_note_input_events(note_input, resolve_entry_id(node))
61
+ end
62
+ end
63
+
64
+ def bind_entry_note_input_events(note_input, entry_id)
65
+ note_input.on(:mouse_button_press) { |_| activate_entry_note_input(note_input) }
66
+ note_input.on(:key_press) { |event| handle_entry_note_key_press(note_input, entry_id, event) }
67
+ note_input.connect('returnPressed') { |_| handle_entry_note_commit(note_input, entry_id) }
68
+ note_input.on(:focus_out) { |_| handle_entry_note_focus_out(note_input) }
69
+ end
70
+
71
+ def resolve_entry_id(node)
72
+ node.fetch(:entry_id, node.fetch(:id).to_s.sub('entry:', ''))
73
+ end
74
+
75
+ def handle_entry_note_commit(note_input, entry_id)
76
+ return if note_input.is_read_only
77
+
78
+ note = note_input.text.to_s
79
+ deactivate_entry_note_input(note_input)
80
+ return unless on_entry_note_change
81
+
82
+ on_entry_note_change.call(entry_id, note)
83
+ end
84
+
85
+ def activate_entry_note_input(note_input)
86
+ note_input.set_read_only(false)
87
+ note_input.set_focus
88
+ end
89
+
90
+ def deactivate_entry_note_input(note_input)
91
+ note_input.set_read_only(true)
92
+ note_input.clear_focus
93
+ end
94
+
95
+ def handle_entry_note_key_press(note_input, entry_id, event)
96
+ return unless enter_key?(event)
97
+
98
+ handle_entry_note_commit(note_input, entry_id)
99
+ end
100
+
101
+ def handle_entry_note_focus_out(note_input)
102
+ deactivate_entry_note_input(note_input)
103
+ end
104
+
105
+ def enter_key?(event)
106
+ key = event_key_code(event)
107
+ [Qt::Key_Return, Qt::Key_Enter].include?(key)
108
+ end
109
+
110
+ def event_key_code(event)
111
+ return event[:a] if event.is_a?(Hash) && event.key?(:a)
112
+ return event['a'] if event.is_a?(Hash) && event.key?('a')
113
+
114
+ event.respond_to?(:key) ? event.key : nil
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QTimetrap
4
+ module Entries
5
+ # Entry-leaf helpers for editable task combo-box controls.
6
+ module LeafTaskHelpers
7
+ private
8
+
9
+ def build_entry_task_input(row, node)
10
+ QComboBox.new(row).tap do |task_input|
11
+ task_input.set_object_name('entry_node_entry_task')
12
+ task_input.set_editable(true)
13
+ task_input.instance_variable_set(:@qtimetrap_task_line_edit, build_entry_task_line_edit(row))
14
+ task_input.set_line_edit(entry_task_line_edit(task_input))
15
+ task_input.set_placeholder_text('task')
16
+ task_input.set_minimum_width(220)
17
+ populate_entry_task_options(task_input, node)
18
+ bind_entry_task_input_events(task_input, resolve_entry_id(node))
19
+ end
20
+ end
21
+
22
+ def build_entry_task_line_edit(row)
23
+ QLineEdit.new(row).tap do |line_edit|
24
+ line_edit.set_object_name('entry_node_entry_task_input')
25
+ end
26
+ end
27
+
28
+ def populate_entry_task_options(task_input, node)
29
+ current_task = node.fetch(:task_name, '').to_s
30
+ suggestions = Array(task_suggestions_for_project&.call(node.fetch(:project_name, '').to_s))
31
+ options = ([current_task] + suggestions.map(&:to_s)).reject(&:empty?).uniq
32
+ options.each { |task_name| task_input.add_item(task_name) }
33
+ task_input.set_current_text(current_task)
34
+ deactivate_entry_task_input(task_input)
35
+ end
36
+
37
+ def bind_entry_task_input_events(task_input, entry_id)
38
+ input = entry_task_line_edit(task_input)
39
+ input.on(:mouse_button_press) { |_| activate_entry_task_input(task_input) }
40
+ input.on(:key_press) { |event| handle_entry_task_key_press(task_input, entry_id, event) }
41
+ input.connect('returnPressed') { |_| handle_entry_task_commit(task_input, entry_id) }
42
+ input.on(:focus_out) { |_| handle_entry_task_focus_out(task_input) }
43
+ task_input.connect('textActivated') { |_| handle_entry_task_commit(task_input, entry_id, force: true) }
44
+ end
45
+
46
+ def activate_entry_task_input(task_input)
47
+ entry_task_line_edit(task_input).set_read_only(false)
48
+ task_input.set_focus
49
+ end
50
+
51
+ def deactivate_entry_task_input(task_input)
52
+ entry_task_line_edit(task_input).set_read_only(true)
53
+ task_input.clear_focus
54
+ end
55
+
56
+ def handle_entry_task_key_press(task_input, entry_id, event)
57
+ return unless enter_key?(event)
58
+
59
+ handle_entry_task_commit(task_input, entry_id)
60
+ end
61
+
62
+ def handle_entry_task_commit(task_input, entry_id, force: false)
63
+ return if entry_task_line_edit(task_input).is_read_only && !force
64
+
65
+ task_name = task_input.current_text.to_s
66
+ deactivate_entry_task_input(task_input)
67
+ return unless on_entry_task_change
68
+
69
+ on_entry_task_change.call(entry_id, task_name)
70
+ end
71
+
72
+ def handle_entry_task_focus_out(task_input)
73
+ deactivate_entry_task_input(task_input)
74
+ end
75
+
76
+ def entry_task_line_edit(task_input)
77
+ task_input.instance_variable_get(:@qtimetrap_task_line_edit)
78
+ end
79
+ end
80
+ end
81
+ end