thread-order 1.3.1__tar.gz → 1.3.2__tar.gz

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 (24) hide show
  1. {thread_order-1.3.1/thread_order.egg-info → thread_order-1.3.2}/PKG-INFO +1 -1
  2. {thread_order-1.3.1 → thread_order-1.3.2}/thread_order/__init__.py +1 -1
  3. {thread_order-1.3.1 → thread_order-1.3.2}/thread_order/ui/app.py +339 -109
  4. {thread_order-1.3.1 → thread_order-1.3.2/thread_order.egg-info}/PKG-INFO +1 -1
  5. {thread_order-1.3.1 → thread_order-1.3.2}/LICENSE +0 -0
  6. {thread_order-1.3.1 → thread_order-1.3.2}/README.md +0 -0
  7. {thread_order-1.3.1 → thread_order-1.3.2}/pyproject.toml +0 -0
  8. {thread_order-1.3.1 → thread_order-1.3.2}/setup.cfg +0 -0
  9. {thread_order-1.3.1 → thread_order-1.3.2}/tests/test_graph.py +0 -0
  10. {thread_order-1.3.1 → thread_order-1.3.2}/tests/test_init.py +0 -0
  11. {thread_order-1.3.1 → thread_order-1.3.2}/tests/test_scheduler.py +0 -0
  12. {thread_order-1.3.1 → thread_order-1.3.2}/thread_order/cli/__init__.py +0 -0
  13. {thread_order-1.3.1 → thread_order-1.3.2}/thread_order/cli/app.py +0 -0
  14. {thread_order-1.3.1 → thread_order-1.3.2}/thread_order/graph.py +0 -0
  15. {thread_order-1.3.1 → thread_order-1.3.2}/thread_order/graph_summary.py +0 -0
  16. {thread_order-1.3.1 → thread_order-1.3.2}/thread_order/logger.py +0 -0
  17. {thread_order-1.3.1 → thread_order-1.3.2}/thread_order/scheduler.py +0 -0
  18. {thread_order-1.3.1 → thread_order-1.3.2}/thread_order/timer.py +0 -0
  19. {thread_order-1.3.1 → thread_order-1.3.2}/thread_order/ui/__init__.py +0 -0
  20. {thread_order-1.3.1 → thread_order-1.3.2}/thread_order.egg-info/SOURCES.txt +0 -0
  21. {thread_order-1.3.1 → thread_order-1.3.2}/thread_order.egg-info/dependency_links.txt +0 -0
  22. {thread_order-1.3.1 → thread_order-1.3.2}/thread_order.egg-info/entry_points.txt +0 -0
  23. {thread_order-1.3.1 → thread_order-1.3.2}/thread_order.egg-info/requires.txt +0 -0
  24. {thread_order-1.3.1 → thread_order-1.3.2}/thread_order.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: thread-order
3
- Version: 1.3.1
3
+ Version: 1.3.2
4
4
  Summary: A lightweight framework for running functions concurrently across multiple threads while maintaining a defined execution order.
5
5
  Author-email: Emilio Reyes <soda480@gmail.com>
6
6
  License-Expression: Apache-2.0
@@ -57,7 +57,7 @@ def __getattr__(name):
57
57
  try:
58
58
  __version__ = _metadata.version(__name__)
59
59
  except _metadata.PackageNotFoundError:
60
- __version__ = '1.3.1'
60
+ __version__ = '1.3.2'
61
61
 
62
62
  if getenv('DEV'):
63
63
  __version__ = f'{__version__}+dev'
@@ -1,4 +1,6 @@
1
1
  import queue
2
+ import random
3
+ import time
2
4
  import threading
3
5
  import json
4
6
  from pathlib import Path
@@ -16,10 +18,12 @@ from thread_order import (
16
18
  register_functions
17
19
  )
18
20
 
19
- CARD_WIDTH = 130
21
+ CARD_WIDTH = 80
20
22
  CARD_HEIGHT = 64
23
+ ICON_WIDTH = 46
21
24
 
22
25
  class Runner(tb.Frame):
26
+
23
27
  def __init__(self, master):
24
28
  super().__init__(master)
25
29
  self.master = master
@@ -29,35 +33,58 @@ class Runner(tb.Frame):
29
33
  'FAILED': self._make_swatch('#e74c3c'), # red
30
34
  'SKIPPED': self._make_swatch('#f1c40f'), # yellow
31
35
  }
36
+ self._source_icons = {
37
+ 'user': self._make_swatch('#000000'), # black
38
+ 'state': self._make_swatch('#3498DB'), # blue
39
+ 'module': self._make_swatch('#E67E22'), # orange
40
+ }
41
+ # thread icons
42
+ self._thread_icon_size = 12
43
+ self._thread_icon_grey = self.colors.light
44
+ self._thread_icon_palette = [
45
+ "#E74C3C", "#9B59B6", "#3498DB", "#1ABC9C", "#2ECC71",
46
+ "#F1C40F", "#E67E22", "#16A085", "#2980B9", "#8E44AD",
47
+ "#C0392B", "#27AE60", "#F39C12",
48
+ "#D35400", "#2C3E50", "#34495E", "#7F8C8D",
49
+ "#00B894", "#0984E3", "#6C5CE7", "#E84393",
50
+ "#00CEC9", "#A29BFE",
51
+ ]
52
+ self._thread_icon_cache = {} # color -> PhotoImage (keep refs!)
53
+ self._thread_icon_color = {} # thread_name -> current color
54
+
32
55
  self.setupui()
33
56
  self._uiqueue = queue.Queue()
34
57
  self._poll_uiqueue()
35
58
  self._running = False
59
+ self._run_t0 = None
60
+ self._elapsed_job = None
36
61
 
37
62
  def setupui(self):
38
63
  self.pack()
39
64
 
40
65
  # menubar
41
- menubar = tk.Menu(self.master)
42
- file_menu = tk.Menu(menubar, tearoff=0)
43
- file_menu.add_command(label='Open Tasks', command=self.open_tasks)
44
- file_menu.add_command(label='Open State', command=self.open_state)
45
- file_menu.add_separator()
46
- file_menu.add_command(label='Exit', command=self.master.quit)
47
- menubar.add_cascade(label='File', menu=file_menu)
48
-
49
- options_menu = tk.Menu(menubar, tearoff=0)
66
+ self.menubar = tk.Menu(self.master)
67
+
68
+ self.file_menu = tk.Menu(self.menubar, tearoff=0)
69
+ self.file_menu.add_command(label='Open Tasks', command=self.open_tasks)
70
+ self.file_menu.add_command(label='Open State', command=self.open_state)
71
+ self.file_menu.add_separator()
72
+ self.file_menu.add_command(label='Exit', command=self.master.quit)
73
+ self.menubar.add_cascade(label='File', menu=self.file_menu)
74
+
75
+ self.options_menu = tk.Menu(self.menubar, tearoff=0)
50
76
  self.log_all_var = tk.BooleanVar(value=False)
51
- options_menu.add_checkbutton(label='Log All', variable=self.log_all_var)
77
+ self.options_menu.add_checkbutton(label='Log All', variable=self.log_all_var)
52
78
  self.skip_dependents_var = tk.BooleanVar(value=False)
53
- options_menu.add_checkbutton(label='Skip Dependents', variable=self.skip_dependents_var)
54
- menubar.add_cascade(label='Options', menu=options_menu)
79
+ self.options_menu.add_checkbutton(
80
+ label='Skip Dependents', variable=self.skip_dependents_var)
81
+ self.menubar.add_cascade(label='Options', menu=self.options_menu)
55
82
 
56
- help_menu = tk.Menu(menubar, tearoff=0)
57
- help_menu.add_command(label='About', command=self.show_about)
58
- menubar.add_cascade(label='Help', menu=help_menu)
83
+ self.help_menu = tk.Menu(self.menubar, tearoff=0)
84
+ self.help_menu.add_command(label='About', command=self.show_about)
85
+ self.menubar.add_cascade(label='Help', menu=self.help_menu)
59
86
 
60
- self.master.config(menu=menubar)
87
+ self.master.config(menu=self.menubar)
61
88
 
62
89
  top_frame = tb.Frame(self.master, padding=4)
63
90
  top_frame.pack(fill=BOTH, expand=False)
@@ -100,170 +127,216 @@ class Runner(tb.Frame):
100
127
  tab3 = tb.Frame(self.notebook)
101
128
  tab4 = tb.Frame(self.notebook)
102
129
 
103
- frame_state_top = tb.Frame(tab1)
104
- frame_state_bot = tb.Frame(tab1)
130
+ state_frame = tb.Frame(tab1)
131
+ state_frame.pack(fill=BOTH, expand=True, padx=4, pady=4)
132
+
133
+ frame_state_top = tb.Frame(state_frame)
134
+ frame_state_bot = tb.Frame(state_frame)
105
135
  frame_state_top.pack(fill=X, expand=False)
106
136
  frame_state_bot.pack(fill=BOTH, expand=True)
107
137
 
138
+ legend_row = tb.Frame(frame_state_top)
139
+ legend_row.pack(side=LEFT)
140
+ self._make_legend_card(legend_row, 'User\nEntry', bootstyle='dark')
141
+ self._make_legend_card(legend_row, 'State\nFile', bootstyle='primary')
142
+ self._make_legend_card(legend_row, 'Module\nFile', bootstyle='warning')
143
+
144
+ entry_frame = tb.Frame(frame_state_top)
145
+ entry_frame.pack(side=LEFT, fill=X, expand=True)
146
+
108
147
  self.key_value = tb.StringVar(value='')
109
- entry_key_value = tb.Entry(
110
- frame_state_top,
148
+ self.entry_key_value = tb.Entry(
149
+ entry_frame,
111
150
  width=50,
112
151
  justify='left',
113
152
  textvariable=self.key_value)
114
- entry_key_value.pack(side=LEFT, padx=4, pady=4, fill=X, expand=True)
115
- button_key_value = tb.Button(
116
- frame_state_top,
153
+ self.entry_key_value.pack(side=LEFT, padx=4, pady=4, fill=X, expand=True)
154
+ self.button_key_value = tb.Button(
155
+ entry_frame,
117
156
  text='Add Key Value',
118
157
  command=self.add_key_value,
119
158
  state='enabled',
120
159
  width=12,
121
160
  bootstyle='primary')
122
- entry_key_value.bind(
161
+ self.entry_key_value.bind(
123
162
  '<Return>',
124
- lambda e: button_key_value.invoke())
125
- button_key_value.pack(side=LEFT, padx=4, pady=4)
163
+ lambda e: self.button_key_value.invoke())
164
+ self.button_key_value.pack(side=LEFT, padx=4, pady=4)
165
+
166
+ state_table_frame = tb.Frame(frame_state_bot)
167
+ state_table_frame.pack(fill=BOTH, expand=True)
126
168
  self.table_state = Tableview(
127
- master=frame_state_bot,
128
- coldata=['Key', 'Value', 'Source', ''],
169
+ master=state_table_frame,
170
+ coldata=['Key', 'Value'],
129
171
  rowdata=[],
130
172
  paginated=False,
131
173
  autofit=False,
132
174
  searchable=False,
133
175
  bootstyle='primary',
134
- yscrollbar=True,
176
+ yscrollbar=False,
135
177
  stripecolor=(self.colors.light, None),
136
178
  )
137
- self.table_state.pack(fill=BOTH, expand=True)
138
- self.table_state.view.column(self.table_state.get_columns()[-1].cid, stretch=True)
179
+ self.table_state.load_table_data()
180
+ table_state_view = self.table_state.view
181
+ self.state_vscroll = tb.Scrollbar(
182
+ state_table_frame, orient='vertical', command=table_state_view.yview)
183
+ table_state_view.configure(
184
+ yscrollcommand=self._autohide_scrollbar(self.state_vscroll),
185
+ show='tree headings')
186
+ table_state_cols = self.table_state.get_columns()
187
+ table_state_view.heading('#0', text='')
188
+ table_state_view.column(
189
+ '#0', width=ICON_WIDTH, minwidth=ICON_WIDTH, stretch=False, anchor='center')
190
+ table_state_key_col = table_state_cols[0].cid
191
+ table_state_view.heading(table_state_key_col, anchor='w')
192
+ table_state_view.column(table_state_key_col, stretch=False)
193
+ table_state_value_col = table_state_cols[1].cid
194
+ table_state_view.heading(table_state_value_col, anchor='w')
195
+ table_state_view.column(table_state_value_col, stretch=True)
196
+ self.state_vscroll.pack(side=RIGHT, fill=Y, padx=(4, 0))
197
+ self.table_state.pack(side=LEFT, fill=BOTH, expand=True)
139
198
  self.table_state.autofit_columns()
140
199
 
141
200
  # TASKS TAB
142
201
  tasks_frame = tb.Frame(tab2)
143
202
  tasks_frame.pack(fill=BOTH, expand=True, padx=4, pady=4)
144
-
145
203
  total_tasks_frame = tb.Frame(tasks_frame)
146
204
  total_tasks_frame.pack(fill=X, expand=False)
147
205
  self.tasks_total_var = tb.IntVar(value=0)
148
206
  self._make_counter(total_tasks_frame, 'Total', self.tasks_total_var, bootstyle='dark')
149
-
207
+ tasks_table_frame = tb.Frame(tasks_frame)
208
+ tasks_table_frame.pack(fill=BOTH, expand=True)
150
209
  self.table_tasks = Tableview(
151
- master=tasks_frame,
210
+ master=tasks_table_frame,
152
211
  coldata=['Tasks', 'Dependencies'],
153
212
  rowdata=[],
154
213
  paginated=False,
155
214
  autofit=False,
156
215
  searchable=False,
157
216
  bootstyle='primary',
158
- yscrollbar=True,
217
+ yscrollbar=False,
159
218
  stripecolor=(self.colors.light, None),
160
219
  disable_right_click=True,
161
220
  )
162
221
  self.table_tasks.load_table_data()
163
- self.table_tasks.pack(fill=BOTH, expand=True, padx=4, pady=4)
164
222
  table_tasks_view = self.table_tasks.view
223
+ self.tasks_vscroll = tb.Scrollbar(
224
+ tasks_table_frame, orient='vertical', command=table_tasks_view.yview)
225
+ table_tasks_view.configure(
226
+ yscrollcommand=self._autohide_scrollbar(self.tasks_vscroll),
227
+ show='tree headings')
228
+ table_tasks_view.heading('#0', text='#')
229
+ table_tasks_view.column('#0', stretch=False, anchor='w', width=40, minwidth=40)
165
230
  table_tasks_cols = self.table_tasks.get_columns()
166
231
  table_tasks_task_col = table_tasks_cols[0].cid
167
232
  table_tasks_view.heading(table_tasks_task_col, anchor='w')
168
- table_tasks_view.column(
169
- table_tasks_task_col, stretch=True, anchor='w', width=400, minwidth=120)
170
- table_tasks_view.configure(show='tree headings')
171
- table_tasks_view.heading('#0', text='#')
172
- table_tasks_view.column(
173
- '#0',
174
- width=40,
175
- minwidth=40,
176
- stretch=False,
177
- anchor='w'
178
- )
233
+ table_tasks_deps_col = table_tasks_cols[1].cid
234
+ table_tasks_view.heading(table_tasks_deps_col, anchor='w')
235
+ table_tasks_view.column(table_tasks_deps_col, stretch=True, anchor='w')
236
+ self.tasks_vscroll.pack(side=RIGHT, fill=Y, padx=(4, 0))
237
+ self.table_tasks.pack(side=LEFT, fill=BOTH, expand=True)
238
+ self.table_tasks.autofit_columns()
179
239
 
180
240
  # THREADS TAB
181
241
  threads_frame = tb.Frame(tab3)
182
242
  threads_frame.pack(fill=BOTH, expand=True, padx=4, pady=4)
183
-
184
243
  thread_viewer_frame = tb.Frame(threads_frame)
185
244
  thread_viewer_frame.pack(fill=X, expand=False)
186
-
187
245
  self.queued_var = tb.IntVar(value=0)
188
246
  self.active_var = tb.IntVar(value=0)
189
247
  self.closed_var = tb.IntVar(value=0)
190
-
191
248
  self._make_counter(thread_viewer_frame, 'Queued', self.queued_var, bootstyle='dark')
192
249
  self._make_counter(thread_viewer_frame, 'Active', self.active_var, bootstyle='primary')
193
250
  self._make_counter(thread_viewer_frame, 'Closed', self.closed_var, bootstyle='secondary')
194
-
251
+ threads_table_frame = tb.Frame(threads_frame)
252
+ threads_table_frame.pack(fill=BOTH, expand=True)
195
253
  self.table_threads = Tableview(
196
- master=threads_frame,
254
+ master=threads_table_frame,
197
255
  coldata=['Thread', 'Task'],
198
256
  rowdata=[(f'thread_{i}', '') for i in range(int(self.workers.get()))],
199
257
  paginated=False,
200
258
  autofit=False,
201
259
  searchable=False,
202
260
  bootstyle='primary',
203
- yscrollbar=True,
261
+ yscrollbar=False,
204
262
  stripecolor=(self.colors.light, None),
205
263
  disable_right_click=True,
206
264
  )
207
265
  self.table_threads.load_table_data()
208
- self.table_threads.pack(fill=BOTH, expand=True, padx=4, pady=4)
209
266
  table_threads_view = self.table_threads.view
267
+ self.threads_vscroll = tb.Scrollbar(
268
+ threads_table_frame, orient='vertical', command=table_threads_view.yview)
269
+ table_threads_view.configure(
270
+ yscrollcommand=self._autohide_scrollbar(self.threads_vscroll),
271
+ show='tree headings',
272
+ selectmode='none')
210
273
  table_threads_cols = self.table_threads.get_columns()
274
+ table_threads_view.heading('#0', text='')
275
+ table_threads_view.column(
276
+ '#0', width=ICON_WIDTH, minwidth=ICON_WIDTH, stretch=False, anchor='center')
211
277
  table_threads_task_col = table_threads_cols[1].cid
212
- table_threads_view.heading(table_threads_task_col, anchor="w")
278
+ table_threads_view.heading(table_threads_task_col, anchor='w')
213
279
  table_threads_view.column(
214
- table_threads_task_col, stretch=True, anchor="w", width=400, minwidth=120)
280
+ table_threads_task_col, stretch=True, anchor='w')
281
+ table_threads_view.selection_remove(table_threads_view.selection())
282
+ table_threads_view.focus('')
283
+ self.threads_vscroll.pack(side=RIGHT, fill=Y, padx=(4, 0))
284
+ self.table_threads.pack(side=LEFT, fill=BOTH, expand=True)
285
+ self.table_threads.autofit_columns()
286
+ self._init_thread_icons()
215
287
 
216
288
  # RUN TAB
217
289
  run_frame = tb.Frame(tab4)
218
290
  run_frame.pack(fill=BOTH, expand=True, padx=4, pady=4)
219
-
220
291
  summary_frame = tb.Frame(run_frame)
221
292
  summary_frame.pack(fill=X, expand=False)
222
-
223
293
  self.total_var = tb.IntVar(value=0)
224
294
  self.passed_var = tb.IntVar(value=0)
225
295
  self.failed_var = tb.IntVar(value=0)
226
296
  self.skipped_var = tb.IntVar(value=0)
227
-
228
297
  self._make_counter(summary_frame, 'Total', self.total_var, bootstyle='dark')
229
298
  self._make_counter(summary_frame, 'Passed', self.passed_var, bootstyle='success')
230
299
  self._make_counter(summary_frame, 'Failed', self.failed_var, bootstyle='danger')
231
300
  self._make_counter(summary_frame, 'Skipped', self.skipped_var, bootstyle='warning')
232
-
301
+ run_table_frame = tb.Frame(run_frame)
302
+ run_table_frame.pack(fill=BOTH, expand=True)
233
303
  self.table_run = Tableview(
234
- master=run_frame,
304
+ master=run_table_frame,
235
305
  coldata=['#', 'Task'],
236
306
  rowdata=[],
237
307
  paginated=False,
238
308
  autofit=False,
239
309
  searchable=False,
240
310
  bootstyle='primary',
241
- yscrollbar=True,
311
+ yscrollbar=False,
242
312
  stripecolor=(self.colors.light, None),
243
313
  disable_right_click=True,
244
314
  )
245
315
  self.table_run.load_table_data()
246
- self.table_run.pack(fill=BOTH, expand=True, padx=4, pady=4)
247
-
248
316
  table_run_view = self.table_run.view
249
- cols = self.table_run.get_columns()
250
- num_col = cols[0].cid
251
- task_col = cols[1].cid
252
- table_run_view.configure(show='tree headings')
253
- ICON_W = 46
317
+ self.run_vscroll = tb.Scrollbar(
318
+ run_table_frame, orient='vertical', command=table_run_view.yview)
319
+ table_run_view.configure(
320
+ yscrollcommand=self._autohide_scrollbar(self.run_vscroll),
321
+ show='tree headings')
322
+ table_run_cols = self.table_run.get_columns()
254
323
  table_run_view.heading('#0', text='')
255
- table_run_view.column('#0', width=ICON_W, minwidth=ICON_W, stretch=False, anchor='center')
256
- table_run_view.heading(num_col, anchor='w')
257
- table_run_view.column(num_col, stretch=False, anchor='w', width=40, minwidth=40)
258
- table_run_view.heading(task_col, anchor='w')
259
- table_run_view.column(task_col, stretch=True, anchor='w', width=400, minwidth=120)
260
-
261
- self.after(0, self.hide_all_hscrollbars)
324
+ table_run_view.column(
325
+ '#0', width=ICON_WIDTH, minwidth=ICON_WIDTH, stretch=False, anchor='center')
326
+ table_run_num_col = table_run_cols[0].cid
327
+ table_run_view.heading(table_run_num_col, anchor='w')
328
+ table_run_view.column(table_run_num_col, stretch=False, anchor='w', width=40, minwidth=40)
329
+ table_run_task_col = table_run_cols[1].cid
330
+ table_run_view.heading(table_run_task_col, anchor='w')
331
+ table_run_view.column(table_run_task_col, stretch=True, anchor='w')
332
+ self.run_vscroll.pack(side=RIGHT, fill=Y, padx=(4, 0))
333
+ self.table_run.pack(side=LEFT, fill=BOTH, expand=True)
262
334
 
263
335
  self.notebook.add(tab1, text='State')
264
336
  self.notebook.add(tab2, text='Tasks')
265
337
  self.notebook.add(tab3, text='Threads')
266
338
  self.notebook.add(tab4, text='Run')
339
+ self.after_idle(self.hide_all_hscrollbars)
267
340
 
268
341
  # STATUS BAR
269
342
  self.footer = tb.Frame(self.master, padding=(8, 4))
@@ -275,7 +348,7 @@ class Runner(tb.Frame):
275
348
  font=('Segoe UI', 8),
276
349
  anchor='w'
277
350
  )
278
- self.duration_label.pack(side=LEFT, fill=X, expand=False)
351
+ self.duration_label.pack(side=LEFT, fill=None, expand=False, padx=(0, 2))
279
352
  self.running_frame = tb.Frame(self.footer)
280
353
  self.progress_var = tb.IntVar(value=0)
281
354
  self.progress = tb.Progressbar(
@@ -283,7 +356,7 @@ class Runner(tb.Frame):
283
356
  mode='determinate',
284
357
  variable=self.progress_var,
285
358
  )
286
- self.progress.pack(side=LEFT, fill=X, expand=True)
359
+ self.progress.pack(side=LEFT, fill=X, expand=True, padx=(2, 0))
287
360
  self.percent_var = tb.StringVar(value="")
288
361
  self.percent_label = tb.Label(
289
362
  self.running_frame,
@@ -302,7 +375,7 @@ class Runner(tb.Frame):
302
375
  key = key_value_split[0].strip()
303
376
  value = key_value_split[1].strip()
304
377
  if value:
305
- self.table_state.insert_row(index=0, values=(key, value, 'user', ''))
378
+ self._upsert_state_row(key, value, 'user')
306
379
  self.table_state.autofit_columns()
307
380
  self.key_value.set('')
308
381
 
@@ -312,6 +385,7 @@ class Runner(tb.Frame):
312
385
  self.table_threads.delete_rows()
313
386
  self.table_threads.insert_rows(0, rowdata=[(f'thread_{i}', '') for i in range(workers)])
314
387
  self.table_threads.autofit_columns()
388
+ self._init_thread_icons()
315
389
 
316
390
  def open_tasks(self):
317
391
  try:
@@ -372,7 +446,6 @@ class Runner(tb.Frame):
372
446
  )
373
447
  if not path:
374
448
  return
375
-
376
449
  self.notebook.select(0)
377
450
  self._load_state_from_json(path)
378
451
  self.duration_var.set(f'Loaded state from {Path(path).name}')
@@ -391,20 +464,39 @@ class Runner(tb.Frame):
391
464
 
392
465
  def _on_scheduler_start_ui(self, meta):
393
466
  self._set_running_ui(True)
394
- self.duration_var.set('')
467
+
468
+ # determinate progress from the start
469
+ self.progress.configure(mode='determinate')
395
470
  self.progress_var.set(0)
396
471
  self.percent_var.set('0%')
397
472
 
473
+ # elapsed timer
474
+ self.duration_var.set('00:00:00')
475
+ self._start_elapsed_ticker()
476
+
398
477
  def on_task_run_ui(self, task_name, thread_name):
399
478
  thread_number = _get_thread_number(thread_name)
400
- if thread_number is not None:
401
- self.active_var.set(self.active_var.get() + 1)
402
- self.queued_var.set(max(0, self.queued_var.get() - 1))
403
- children = self.table_threads.view.get_children('')
404
- if thread_number >= len(children):
405
- return
406
- iid = children[thread_number]
407
- self.table_threads.view.item(iid, values=(thread_name, task_name))
479
+ if thread_number is None:
480
+ return
481
+
482
+ self.active_var.set(self.active_var.get() + 1)
483
+ self.queued_var.set(max(0, self.queued_var.get() - 1))
484
+
485
+ children = self.table_threads.view.get_children('')
486
+ if thread_number >= len(children):
487
+ return
488
+
489
+ tv = self.table_threads.view
490
+ iid = children[thread_number]
491
+
492
+ # update values
493
+ tv.item(iid, values=(thread_name, task_name))
494
+
495
+ # update icon color (must change if reassigned)
496
+ prev = self._thread_icon_color.get(thread_name, self._thread_icon_grey)
497
+ new_color = self._pick_new_thread_color(prev)
498
+ tv.item(iid, image=self._get_thread_icon(new_color))
499
+ self._thread_icon_color[thread_name] = new_color
408
500
 
409
501
  def on_task_done_ui(self, task_name, thread_name, status, count, total):
410
502
  key = str(status.value).upper()
@@ -413,10 +505,10 @@ class Runner(tb.Frame):
413
505
  iid = self.table_run.view.get_children('')[0]
414
506
  self.table_run.view.item(iid, image=icon)
415
507
  # highlight row that was just inserted
416
- tv = self.table_run.view
417
- tv.selection_set(iid)
418
- tv.focus(iid)
419
- tv.see(iid)
508
+ tabke_run_view = self.table_run.view
509
+ tabke_run_view.selection_set(iid)
510
+ tabke_run_view.focus(iid)
511
+ tabke_run_view.see(iid)
420
512
 
421
513
  if key == 'PASSED':
422
514
  self.passed_var.set(self.passed_var.get() + 1)
@@ -429,8 +521,14 @@ class Runner(tb.Frame):
429
521
  if thread_number is not None:
430
522
  self.active_var.set(self.active_var.get() - 1)
431
523
  self.closed_var.set(self.closed_var.get() + 1)
432
- iid = self.table_threads.view.get_children('')[thread_number]
433
- self.table_threads.view.item(iid, values=(thread_name, ''))
524
+
525
+ table_threads_view = self.table_threads.view
526
+ iid = table_threads_view.get_children('')[thread_number]
527
+ table_threads_view.item(iid, values=(thread_name, ''))
528
+
529
+ # back to grey when idle
530
+ table_threads_view.item(iid, image=self._get_thread_icon(self._thread_icon_grey))
531
+ self._thread_icon_color[thread_name] = self._thread_icon_grey
434
532
  else:
435
533
  self.closed_var.set(self.closed_var.get() + 1)
436
534
  self.queued_var.set(max(0, self.queued_var.get() - 1))
@@ -441,6 +539,7 @@ class Runner(tb.Frame):
441
539
  self.percent_var.set(f'{pct}%')
442
540
 
443
541
  def on_scheduler_done_ui(self, summary):
542
+ self._stop_elapsed_ticker()
444
543
  self._set_running_ui(False)
445
544
  duration = summary['duration']
446
545
  self.duration_var.set(f'Completed in {duration:.2f}s')
@@ -448,11 +547,20 @@ class Runner(tb.Frame):
448
547
  def _set_running_ui(self, running):
449
548
  self.spinbox_workers.configure(state='disabled' if running else 'enabled')
450
549
  self.run_button.configure(state='disabled' if running else 'enabled')
550
+ self.button_key_value.configure(state='disabled' if running else 'enabled')
551
+ self.entry_key_value.configure(state='disabled' if running else 'normal')
552
+ self._set_menu_state('disabled' if running else 'normal')
451
553
  if running:
452
554
  self._show_running_footer()
453
555
  else:
454
556
  self._hide_running_footer()
455
557
 
558
+ def _set_menu_state(self, state):
559
+ # state is 'normal' or 'disabled'
560
+ # disable top-level cascades so nothing inside can be clicked
561
+ self.menubar.entryconfig('File', state=state)
562
+ self.menubar.entryconfig('Options', state=state)
563
+
456
564
  def run_tasks(self):
457
565
  if getattr(self, '_running', False) or not hasattr(self, '_marked_functions'):
458
566
  return
@@ -483,9 +591,10 @@ class Runner(tb.Frame):
483
591
  self.scheduler.on_scheduler_done(self.on_scheduler_done)
484
592
  self.scheduler.start()
485
593
  except (Exception, SystemExit) as e:
594
+ self._uiqueue_put(self._set_running_ui, False)
595
+ self._uiqueue_put(self._stop_elapsed_ticker)
486
596
  # surface the error on the UI thread
487
597
  self._uiqueue_put(messagebox.showerror, 'Run failed', str(e))
488
- self._uiqueue_put(self._set_running_ui, False)
489
598
  finally:
490
599
  self._running = False
491
600
  print('state' + json.dumps(self.scheduler.sanitized_state, indent=2, default=str))
@@ -543,6 +652,30 @@ class Runner(tb.Frame):
543
652
  anchor='w'
544
653
  ).pack(anchor='w')
545
654
 
655
+ def _make_legend_card(self, parent, title, bootstyle='secondary'):
656
+ card = tb.Frame(
657
+ parent,
658
+ width=CARD_WIDTH,
659
+ height=CARD_HEIGHT,
660
+ bootstyle=bootstyle
661
+ )
662
+ card.pack_propagate(False)
663
+ card.pack(side=LEFT, padx=4, pady=4)
664
+
665
+ tb.Label(
666
+ card,
667
+ text=title,
668
+ font=('Segoe UI', 10, 'bold'),
669
+ bootstyle='inverse-' + bootstyle,
670
+ anchor='w'
671
+ ).pack(anchor='w')
672
+
673
+ tb.Label(
674
+ card,
675
+ bootstyle='inverse-' + bootstyle,
676
+ anchor='w'
677
+ ).pack(anchor='w')
678
+
546
679
  def _renumber_table(self, table: Tableview, start=1):
547
680
  for i, iid in enumerate(table.view.get_children(""), start=start):
548
681
  table.view.item(iid, text=str(i))
@@ -576,7 +709,7 @@ class Runner(tb.Frame):
576
709
  value_str = json.dumps(value)
577
710
  else:
578
711
  value_str = str(value)
579
- self._upsert_state_row(key, value_str, Path(path).name)
712
+ self._upsert_state_row(key, value_str, 'state')
580
713
 
581
714
  self.table_state.autofit_columns()
582
715
 
@@ -596,13 +729,18 @@ class Runner(tb.Frame):
596
729
  return state
597
730
 
598
731
  def _upsert_state_row(self, key, value_str, source):
599
- tv = self.table_state.view
600
- for iid in tv.get_children(''):
601
- k = tv.item(iid, 'values')[0]
732
+ table_state_view = self.table_state.view
733
+ icon = self._source_icons[source]
734
+ for iid in table_state_view.get_children(''):
735
+ k = table_state_view.item(iid, 'values')[0]
602
736
  if k == key:
603
- tv.item(iid, values=(key, value_str, source, ''))
737
+ table_state_view.item(iid, values=(key, value_str), image=icon)
738
+ table_state_view.move(iid, '', 0) # move to top
739
+ table_state_view.see(iid)
604
740
  return
605
- self.table_state.insert_row(index='end', values=(key, value_str, source, ''))
741
+ row = self.table_state.insert_row(index=0, values=(key, value_str))
742
+ table_state_view.item(row.iid, image=icon)
743
+ table_state_view.see(row.iid)
606
744
 
607
745
  def show_about(self):
608
746
  version = importlib.metadata.version("thread-order")
@@ -641,12 +779,94 @@ class Runner(tb.Frame):
641
779
  # tables
642
780
  self.table_run.delete_rows()
643
781
 
644
- # clear thread assignments
782
+ # clear thread assignments + reset icons
645
783
  tv = self.table_threads.view
646
784
  for iid in tv.get_children(''):
647
785
  thread_name, *_ = tv.item(iid, 'values')
648
786
  tv.item(iid, values=(thread_name, ''))
649
787
 
788
+ self._init_thread_icons()
789
+
790
+ def _get_thread_icon(self, color):
791
+ img = self._thread_icon_cache.get(color)
792
+ if img:
793
+ return img
794
+ size = self._thread_icon_size
795
+ img = tk.PhotoImage(width=size * 2, height=size)
796
+ img.put(color, to=(0, 0, size * 2, size))
797
+ self._thread_icon_cache[color] = img
798
+ return img
799
+
800
+ def _pick_new_thread_color(self, prev):
801
+ choices = [c for c in self._thread_icon_palette if c != prev]
802
+ if not choices:
803
+ return self._thread_icon_palette[0]
804
+ return random.choice(choices)
805
+
806
+ def _init_thread_icons(self):
807
+ tv = self.table_threads.view
808
+ grey = self._get_thread_icon(self._thread_icon_grey)
809
+ self._thread_icon_color.clear()
810
+
811
+ for iid in tv.get_children(''):
812
+ thread_name = tv.item(iid, 'values')[0]
813
+ tv.item(iid, image=grey)
814
+ self._thread_icon_color[thread_name] = self._thread_icon_grey
815
+
816
+ def _start_elapsed_ticker(self):
817
+ # cancel existing ticker if any
818
+ if self._elapsed_job is not None:
819
+ try:
820
+ self.after_cancel(self._elapsed_job)
821
+ except Exception:
822
+ pass
823
+ self._elapsed_job = None
824
+
825
+ self._run_t0 = time.monotonic()
826
+ self._tick_elapsed()
827
+
828
+ def _stop_elapsed_ticker(self):
829
+ if self._elapsed_job is not None:
830
+ try:
831
+ self.after_cancel(self._elapsed_job)
832
+ except Exception:
833
+ pass
834
+ self._elapsed_job = None
835
+
836
+ def _tick_elapsed(self):
837
+ if not self._running or self._run_t0 is None:
838
+ return
839
+
840
+ elapsed = int(time.monotonic() - self._run_t0)
841
+ hours = elapsed // 3600
842
+ mins = (elapsed % 3600) // 60
843
+ secs = elapsed % 60
844
+ self.duration_var.set(f"{hours:02d}:{mins:02d}:{secs:02d}")
845
+ # was 500ms; 1s is enough since you display seconds
846
+ self._elapsed_job = self.after(1000, self._tick_elapsed)
847
+
848
+ def _autohide_scrollbar(self, scrollbar):
849
+ def _wrapped(first, last):
850
+ first, last = float(first), float(last)
851
+
852
+ if first <= 0.0 and last >= 1.0:
853
+ # content fully visible → hide scrollbar
854
+ if scrollbar.winfo_ismapped():
855
+ scrollbar.pack_forget()
856
+ else:
857
+ # overflow → show scrollbar
858
+ if not scrollbar.winfo_ismapped():
859
+ scrollbar.pack(side=RIGHT, fill=Y, padx=(4, 0))
860
+
861
+ scrollbar.set(first, last)
862
+
863
+ return _wrapped
864
+
865
+ def _legend_item(parent, icon, text):
866
+ w = tb.Label(parent, image=icon, text=f" {text}", compound="left")
867
+ w.pack(side=LEFT, padx=(8, 0))
868
+ return w
869
+
650
870
  def _maybe_call_setup_state(module, initial_state):
651
871
  """ invoke module-level setup_state(initial_state) if defined
652
872
  """
@@ -655,11 +875,21 @@ def _maybe_call_setup_state(module, initial_state):
655
875
  setup_state_function(initial_state)
656
876
 
657
877
  def hide_tableview_hscroll(table):
658
- for child in table.winfo_children():
659
- # Only kill the scrollbar that is directly under the tableview container
660
- if child.winfo_class() == "TScrollbar":
661
- # This is the outer scrollbar (your horizontal one)
662
- child.pack_forget()
878
+ # Hide only scrollbars wired to xview (horizontal).
879
+ def walk(w):
880
+ for child in w.winfo_children():
881
+ if child.winfo_class() == "TScrollbar":
882
+ try:
883
+ cmd = str(child.cget("command"))
884
+ except Exception:
885
+ cmd = ""
886
+ if "xview" in cmd:
887
+ child.pack_forget()
888
+ child.grid_forget()
889
+ child.place_forget()
890
+ else:
891
+ walk(child)
892
+ walk(table)
663
893
 
664
894
  def _get_thread_number(thread_name, thread_prefix='thread_'):
665
895
  """ Extract the thread index from a thread name.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: thread-order
3
- Version: 1.3.1
3
+ Version: 1.3.2
4
4
  Summary: A lightweight framework for running functions concurrently across multiple threads while maintaining a defined execution order.
5
5
  Author-email: Emilio Reyes <soda480@gmail.com>
6
6
  License-Expression: Apache-2.0
File without changes
File without changes
File without changes