curses-components 26.7.2__tar.gz → 26.9.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 (18) hide show
  1. {curses_components-26.7.2 → curses_components-26.9.2}/PKG-INFO +21 -4
  2. {curses_components-26.7.2 → curses_components-26.9.2}/README.md +19 -2
  3. {curses_components-26.7.2 → curses_components-26.9.2}/curses_components/__init__.py +2 -1
  4. {curses_components-26.7.2 → curses_components-26.9.2}/curses_components/editor.py +6 -1
  5. {curses_components-26.7.2 → curses_components-26.9.2}/curses_components/grid.py +88 -6
  6. {curses_components-26.7.2 → curses_components-26.9.2}/curses_components/popup.py +109 -7
  7. {curses_components-26.7.2 → curses_components-26.9.2}/curses_components.egg-info/PKG-INFO +21 -4
  8. {curses_components-26.7.2 → curses_components-26.9.2}/curses_components.egg-info/top_level.txt +1 -0
  9. {curses_components-26.7.2 → curses_components-26.9.2}/pyproject.toml +1 -1
  10. {curses_components-26.7.2 → curses_components-26.9.2}/LICENSE +0 -0
  11. {curses_components-26.7.2 → curses_components-26.9.2}/curses_components/theme.py +0 -0
  12. {curses_components-26.7.2 → curses_components-26.9.2}/curses_components.egg-info/SOURCES.txt +0 -0
  13. {curses_components-26.7.2 → curses_components-26.9.2}/curses_components.egg-info/dependency_links.txt +0 -0
  14. {curses_components-26.7.2 → curses_components-26.9.2}/curses_components.egg-info/requires.txt +0 -0
  15. {curses_components-26.7.2 → curses_components-26.9.2}/examples/grid_editor.py +0 -0
  16. {curses_components-26.7.2 → curses_components-26.9.2}/examples/text_editor.py +0 -0
  17. {curses_components-26.7.2 → curses_components-26.9.2}/setup.cfg +0 -0
  18. {curses_components-26.7.2 → curses_components-26.9.2}/util/show_colors.py +0 -0
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: curses_components
3
- Version: 26.7.2
3
+ Version: 26.9.2
4
4
  Summary: A reusable curses component library for terminal UI (TUI) applications.
5
5
  Author: Cobus Nel
6
6
  License-Expression: MIT
7
7
  Project-URL: Repository, https://github.com/cobusn/curses_components
8
8
  Classifier: Programming Language :: Python :: 3
9
9
  Classifier: Operating System :: OS Independent
10
- Requires-Python: >=3.13
10
+ Requires-Python: >=3.8
11
11
  Description-Content-Type: text/markdown
12
12
  License-File: LICENSE
13
13
  Requires-Dist: pyperclip
@@ -92,10 +92,27 @@ See examples/ for runnable demos:
92
92
  GridComponent key bindings
93
93
  --------------------------
94
94
 
95
+ Grid extensions can display reusable popups through `show_popup`:
96
+
97
+ grid.show_popup('Messages', lines=['first line', 'second line'])
98
+ grid.show_popup('Results', rows=[('Name', 'Alice'), ('State', 'Active')])
99
+
100
+ The popup inherits the grid's configured colors.
101
+
102
+ Commands can be added with a handler that receives the grid and parsed
103
+ arguments:
104
+
105
+ def show_current_column(grid, args):
106
+ column = grid.columns[grid.col_idx]
107
+ grid.show_popup(column, lines=[str(row.get(column, ''))
108
+ for row in grid.data])
109
+
110
+ grid.register_command('values', show_current_column)
111
+
95
112
  Navigation: j/k/h/l or arrow keys, Home/End, Page Up/Down, ^/$
96
113
  Search: / (substring), r/<pattern> (regex), n/N (next/previous)
97
114
  Input mode: : (enter), q (quit), sort, filter, col, freeze, copy,
98
- copyrow, export <file>
115
+ copyrow, count, export <file>
99
116
  Sorting: sort col1 col2! (! suffix = descending)
100
117
  Filtering: filter <val> (current column, wildcards supported)
101
118
  filter reset
@@ -127,7 +144,7 @@ EditorPopup also accepts bold=True (bright text) and bold_background=True.
127
144
  Dependencies
128
145
  ------------
129
146
 
130
- - Python 3.13+
147
+ - Python 3.8+
131
148
  - pyperclip (for copy/copyrow commands in GridComponent)
132
149
 
133
150
  License
@@ -77,10 +77,27 @@ See examples/ for runnable demos:
77
77
  GridComponent key bindings
78
78
  --------------------------
79
79
 
80
+ Grid extensions can display reusable popups through `show_popup`:
81
+
82
+ grid.show_popup('Messages', lines=['first line', 'second line'])
83
+ grid.show_popup('Results', rows=[('Name', 'Alice'), ('State', 'Active')])
84
+
85
+ The popup inherits the grid's configured colors.
86
+
87
+ Commands can be added with a handler that receives the grid and parsed
88
+ arguments:
89
+
90
+ def show_current_column(grid, args):
91
+ column = grid.columns[grid.col_idx]
92
+ grid.show_popup(column, lines=[str(row.get(column, ''))
93
+ for row in grid.data])
94
+
95
+ grid.register_command('values', show_current_column)
96
+
80
97
  Navigation: j/k/h/l or arrow keys, Home/End, Page Up/Down, ^/$
81
98
  Search: / (substring), r/<pattern> (regex), n/N (next/previous)
82
99
  Input mode: : (enter), q (quit), sort, filter, col, freeze, copy,
83
- copyrow, export <file>
100
+ copyrow, count, export <file>
84
101
  Sorting: sort col1 col2! (! suffix = descending)
85
102
  Filtering: filter <val> (current column, wildcards supported)
86
103
  filter reset
@@ -112,7 +129,7 @@ EditorPopup also accepts bold=True (bright text) and bold_background=True.
112
129
  Dependencies
113
130
  ------------
114
131
 
115
- - Python 3.13+
132
+ - Python 3.8+
116
133
  - pyperclip (for copy/copyrow commands in GridComponent)
117
134
 
118
135
  License
@@ -1,7 +1,8 @@
1
1
  # SPDX-License-Identifier: MIT
2
2
  # Copyright (c) 2026 Cobus Nel
3
- __version__ = "26.7.2"
3
+ __version__ = "26.9.2"
4
4
 
5
5
  from curses_components.editor import EditorPopup
6
+ from curses_components.grid import CommandHandler, GridComponent
6
7
  from curses_components.popup import EditorHelpPopup, HelpPopup, ScrollablePopup
7
8
  from curses_components.theme import COLORS, resolve_color
@@ -612,7 +612,12 @@ class EditorPopup:
612
612
  return None
613
613
 
614
614
  if verb == 'help':
615
- EditorHelpPopup(self.stdscr).display()
615
+ EditorHelpPopup(
616
+ self.stdscr,
617
+ fg_color=self.fg_color,
618
+ bg_color=self.bg_color,
619
+ border_color=self.border_color,
620
+ ).display()
616
621
  return None
617
622
 
618
623
  self._status_msg = f'unknown command: {verb}'
@@ -10,12 +10,14 @@ import json
10
10
  import logging
11
11
  import re
12
12
  import time
13
+ from collections import Counter
14
+ from typing import Protocol, Sequence
15
+
13
16
  import pyperclip
14
17
 
15
- from curses_components.popup import HelpPopup
18
+ from curses_components.popup import HelpPopup, ScrollablePopup, TextPopup
16
19
  from curses_components.theme import resolve_color
17
20
 
18
-
19
21
  logging.basicConfig(filename='help_debug.log', level=logging.DEBUG)
20
22
 
21
23
 
@@ -23,6 +25,13 @@ class QuitApplication(Exception):
23
25
  """Custom exception to signal application exit."""
24
26
 
25
27
 
28
+ class CommandHandler(Protocol):
29
+ """Public signature for commands registered by grid extensions."""
30
+
31
+ def __call__(self, grid: "GridComponent", args: Sequence[str]) -> None:
32
+ """Handle a command for the grid and its parsed arguments."""
33
+
34
+
26
35
 
27
36
  class GridComponent:
28
37
  """
@@ -64,6 +73,7 @@ class GridComponent:
64
73
  "col": self._cmd_col,
65
74
  "copy": self._cmd_copy,
66
75
  "copyrow": self._cmd_copyrow,
76
+ "count": self._cmd_count,
67
77
  "export": self._cmd_export,
68
78
  "filter": self._cmd_filter,
69
79
  "freeze": self._cmd_freeze,
@@ -73,6 +83,21 @@ class GridComponent:
73
83
  "sort": self._cmd_sort,
74
84
  }
75
85
 
86
+ def register_command(self, name: str, handler: CommandHandler) -> None:
87
+ """Register an extension command receiving ``(grid, args)``."""
88
+ if not isinstance(name, str):
89
+ raise TypeError("command name must be a string")
90
+ command_name = name.strip().lower()
91
+ if not command_name or any(char.isspace() for char in command_name):
92
+ raise ValueError("command name must be a non-empty single token")
93
+ if not callable(handler):
94
+ raise TypeError("command handler must be callable")
95
+
96
+ def invoke(args):
97
+ return handler(self, args)
98
+
99
+ self.commands[command_name] = invoke
100
+
76
101
  def display(self, data, columns=None, max_rows=10000):
77
102
  """
78
103
  Initializes the curses application and displays the grid.
@@ -164,6 +189,27 @@ class GridComponent:
164
189
  self.error_message = message
165
190
  self.error_message_expiry = time.time() + delay
166
191
 
192
+ def show_popup(self, title, *, rows=None, lines=None, key_col_width=20):
193
+ """Display a scrollable popup with text lines or two-column rows."""
194
+ if (rows is None) == (lines is None):
195
+ raise ValueError("provide exactly one of rows or lines")
196
+
197
+ popup_kwargs = {
198
+ "title": title,
199
+ "fg_color": self.fg_color,
200
+ "bg_color": self.bg_color,
201
+ "border_color": self.border_color,
202
+ }
203
+ if lines is not None:
204
+ TextPopup(self.stdscr, lines, **popup_kwargs).display()
205
+ else:
206
+ ScrollablePopup(
207
+ self.stdscr,
208
+ rows=rows,
209
+ key_col_width=key_col_width,
210
+ **popup_kwargs,
211
+ ).display()
212
+
167
213
  def _get_col_stats(self, col):
168
214
  """Returns (min, max, avg, count) for a numeric column over current data, or None."""
169
215
  values = [float(str(row.get(col, ''))) for row in self.data
@@ -372,6 +418,40 @@ class GridComponent:
372
418
  pyperclip.copy(json.dumps(row, ensure_ascii=False))
373
419
  self.show_error("Row copied as JSON", delay=0.8)
374
420
 
421
+ def _cmd_count(self, _cmds):
422
+ """Display counts of distinct raw values in the current column."""
423
+ if _cmds:
424
+ self.show_error("Usage: count")
425
+ return
426
+ if not self.data or not self.columns:
427
+ self.show_error("No data to count")
428
+ return
429
+
430
+ column = self.columns[self.col_idx]
431
+ values = [row.get(column, '') for row in self.data]
432
+ try:
433
+ counts = Counter(values)
434
+ except TypeError:
435
+ self.show_error("Cannot count unhashable values")
436
+ return
437
+ rows = [("Value", "Count"), ("", "")]
438
+ rows.extend(
439
+ (str(value), str(count))
440
+ for value, count in sorted(
441
+ counts.items(),
442
+ key=lambda item: (-item[1], str(item[0])),
443
+ )
444
+ )
445
+ key_col_width = min(
446
+ 40,
447
+ max(24, max((len(str(value)) for value in counts), default=0) + 3),
448
+ )
449
+ self.show_popup(
450
+ f"{column} ({len(counts)} unique)",
451
+ rows=rows,
452
+ key_col_width=key_col_width,
453
+ )
454
+
375
455
  def _cmd_export(self, _cmds):
376
456
  """Handles the 'export' command, writing current data to a CSV file."""
377
457
  if not _cmds:
@@ -389,8 +469,11 @@ class GridComponent:
389
469
 
390
470
  def _cmd_help(self, _cmds):
391
471
  """Handles the 'help' command, displaying the help screen."""
392
- help_screen = HelpPopup(self.stdscr)
393
- help_screen.display()
472
+ self.show_popup(
473
+ HelpPopup.title,
474
+ rows=HelpPopup(self.stdscr).rows,
475
+ key_col_width=HelpPopup.key_col_width,
476
+ )
394
477
 
395
478
  def _cmd_dollar(self, _cmds):
396
479
  """Handles the '$' command, moving to the last row."""
@@ -662,8 +745,7 @@ class GridComponent:
662
745
  else:
663
746
  self.show_error("No mark set")
664
747
  elif key == ord('?'):
665
- help_screen = HelpPopup(self.stdscr)
666
- help_screen.display()
748
+ self._cmd_help([])
667
749
 
668
750
  def _get_visible_cols(self):
669
751
  """Returns a list of scrollable column names currently visible on screen."""
@@ -3,6 +3,9 @@
3
3
  """Reusable curses popup components."""
4
4
 
5
5
  import curses
6
+ from collections import Counter
7
+
8
+ from curses_components.theme import resolve_color
6
9
 
7
10
 
8
11
  class ScrollablePopup:
@@ -22,16 +25,34 @@ class ScrollablePopup:
22
25
  title = ""
23
26
  key_col_width = 20 # characters reserved for the left column
24
27
 
25
- def __init__(self, stdscr):
28
+ def __init__(self, stdscr, title=None, rows=None, key_col_width=None,
29
+ fg_color=None, bg_color=None, border_color=None):
26
30
  self.stdscr = stdscr
31
+ if title is not None:
32
+ self.title = title
33
+ self._rows = rows
34
+ if key_col_width is not None:
35
+ self.key_col_width = key_col_width
36
+ self.fg_color = fg_color
37
+ self.bg_color = bg_color
38
+ self.border_color = border_color
27
39
  self.scroll_pos = 0
28
40
 
41
+ def _init_colors(self):
42
+ """Initialize color pairs used by the popup."""
43
+ bg = resolve_color(self.bg_color, -1)
44
+ fg = resolve_color(self.fg_color, -1)
45
+ border = resolve_color(self.border_color, -1)
46
+ curses.init_pair(5, fg, bg)
47
+ curses.init_pair(6, border, bg)
48
+
29
49
  @property
30
50
  def rows(self):
31
51
  """Return a list of (left, right) string tuples to display."""
32
- return []
52
+ return self._rows or []
33
53
 
34
54
  def display(self):
55
+ self._init_colors()
35
56
  height, width = self.stdscr.getmaxyx()
36
57
  win_height = min(25, height - 2)
37
58
  win_width = min(80, width - 2)
@@ -44,23 +65,48 @@ class ScrollablePopup:
44
65
  max_scroll = max(0, len(content) - (win_height - 4))
45
66
 
46
67
  while True:
68
+ win.attrset(curses.color_pair(6))
47
69
  win.border()
48
70
  title = self.title
49
- win.addstr(0, max(0, (win_width - len(title)) // 2), title, curses.A_REVERSE)
71
+ title_x = max(0, (win_width - len(title)) // 2)
72
+ win.addstr(
73
+ 0,
74
+ title_x,
75
+ title[:max(0, win_width - title_x - 1)],
76
+ curses.color_pair(6) | curses.A_REVERSE,
77
+ )
50
78
 
51
79
  for i in range(1, win_height - 1):
52
- win.addstr(i, 1, " " * (win_width - 2))
80
+ win.addstr(i, 1, " " * (win_width - 2), curses.color_pair(5))
53
81
 
54
82
  text_y = 2
55
83
  for left, right in content[self.scroll_pos:]:
56
84
  if text_y >= win_height - 2:
57
85
  break
58
- win.addstr(text_y, 2, left, curses.A_BOLD)
86
+ left_width = max(0, self.key_col_width - 3)
87
+ right_width = max(0, win_width - self.key_col_width - 2)
88
+ win.addstr(
89
+ text_y,
90
+ 2,
91
+ left[:left_width],
92
+ curses.color_pair(5) | curses.A_BOLD,
93
+ )
59
94
  if right:
60
- win.addstr(text_y, self.key_col_width, right)
95
+ win.addstr(
96
+ text_y,
97
+ self.key_col_width,
98
+ right[:right_width],
99
+ curses.color_pair(5),
100
+ )
61
101
  text_y += 1
62
102
 
63
- win.addstr(win_height - 2, 2, "Press 'q' to close...")
103
+ footer = "Press 'q' to close..."
104
+ win.addstr(
105
+ win_height - 2,
106
+ 2,
107
+ footer[:max(0, win_width - 4)],
108
+ curses.color_pair(5),
109
+ )
64
110
  win.noutrefresh()
65
111
  curses.doupdate()
66
112
 
@@ -73,6 +119,27 @@ class ScrollablePopup:
73
119
  self.scroll_pos = min(max_scroll, self.scroll_pos + 1)
74
120
 
75
121
 
122
+ class TextPopup(ScrollablePopup):
123
+ """Popup displaying one scrollable line of text per row."""
124
+
125
+ def __init__(self, stdscr, lines, **kwargs):
126
+ super().__init__(stdscr, **kwargs)
127
+ self.lines = [str(line) for line in lines]
128
+
129
+ @property
130
+ def rows(self):
131
+ return [(line, "") for line in self.lines]
132
+
133
+ def display(self):
134
+ original_key_col_width = self.key_col_width
135
+ screen_width = self.stdscr.getmaxyx()[1]
136
+ self.key_col_width = min(80, max(1, screen_width - 2))
137
+ try:
138
+ super().display()
139
+ finally:
140
+ self.key_col_width = original_key_col_width
141
+
142
+
76
143
  class HelpPopup(ScrollablePopup):
77
144
  """Help popup for GridComponent."""
78
145
 
@@ -106,6 +173,7 @@ class HelpPopup(ScrollablePopup):
106
173
  ("sort col1 col2!", "col1 asc, col2 desc (! = descending)"),
107
174
  ("copy", "Copy current cell value"),
108
175
  ("copyrow", "Copy current row as JSON"),
176
+ ("count", "Count distinct values in current column"),
109
177
  ("export <file>", "Export current data to CSV file"),
110
178
  ("ESC", "Exit input mode"),
111
179
  ("", ""),
@@ -131,6 +199,40 @@ class HelpPopup(ScrollablePopup):
131
199
  ]
132
200
 
133
201
 
202
+ class ValueCountPopup(ScrollablePopup):
203
+ """Popup displaying the frequency of each value in a grid column."""
204
+
205
+ title = "Value Counts"
206
+
207
+ def __init__(self, stdscr, column, values, **colors):
208
+ super().__init__(stdscr, **colors)
209
+ self.column = column
210
+ self.counts = Counter(values)
211
+ self.key_col_width = min(
212
+ 40,
213
+ max(
214
+ 24,
215
+ max((len(str(value)) for value in self.counts), default=0) + 3,
216
+ ),
217
+ )
218
+
219
+ @property
220
+ def rows(self):
221
+ rows = [("Value", "Count"), ("", "")]
222
+ rows.extend(
223
+ (str(value), str(count))
224
+ for value, count in sorted(
225
+ self.counts.items(),
226
+ key=lambda item: (-item[1], str(item[0])),
227
+ )
228
+ )
229
+ return rows
230
+
231
+ def display(self):
232
+ self.title = f"{self.column} ({len(self.counts)} unique)"
233
+ super().display()
234
+
235
+
134
236
  class EditorHelpPopup(ScrollablePopup):
135
237
  """Help popup for EditorPopup."""
136
238
 
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: curses_components
3
- Version: 26.7.2
3
+ Version: 26.9.2
4
4
  Summary: A reusable curses component library for terminal UI (TUI) applications.
5
5
  Author: Cobus Nel
6
6
  License-Expression: MIT
7
7
  Project-URL: Repository, https://github.com/cobusn/curses_components
8
8
  Classifier: Programming Language :: Python :: 3
9
9
  Classifier: Operating System :: OS Independent
10
- Requires-Python: >=3.13
10
+ Requires-Python: >=3.8
11
11
  Description-Content-Type: text/markdown
12
12
  License-File: LICENSE
13
13
  Requires-Dist: pyperclip
@@ -92,10 +92,27 @@ See examples/ for runnable demos:
92
92
  GridComponent key bindings
93
93
  --------------------------
94
94
 
95
+ Grid extensions can display reusable popups through `show_popup`:
96
+
97
+ grid.show_popup('Messages', lines=['first line', 'second line'])
98
+ grid.show_popup('Results', rows=[('Name', 'Alice'), ('State', 'Active')])
99
+
100
+ The popup inherits the grid's configured colors.
101
+
102
+ Commands can be added with a handler that receives the grid and parsed
103
+ arguments:
104
+
105
+ def show_current_column(grid, args):
106
+ column = grid.columns[grid.col_idx]
107
+ grid.show_popup(column, lines=[str(row.get(column, ''))
108
+ for row in grid.data])
109
+
110
+ grid.register_command('values', show_current_column)
111
+
95
112
  Navigation: j/k/h/l or arrow keys, Home/End, Page Up/Down, ^/$
96
113
  Search: / (substring), r/<pattern> (regex), n/N (next/previous)
97
114
  Input mode: : (enter), q (quit), sort, filter, col, freeze, copy,
98
- copyrow, export <file>
115
+ copyrow, count, export <file>
99
116
  Sorting: sort col1 col2! (! suffix = descending)
100
117
  Filtering: filter <val> (current column, wildcards supported)
101
118
  filter reset
@@ -127,7 +144,7 @@ EditorPopup also accepts bold=True (bright text) and bold_background=True.
127
144
  Dependencies
128
145
  ------------
129
146
 
130
- - Python 3.13+
147
+ - Python 3.8+
131
148
  - pyperclip (for copy/copyrow commands in GridComponent)
132
149
 
133
150
  License
@@ -1,3 +1,4 @@
1
+ _resume
1
2
  attic
2
3
  build
3
4
  curses_components
@@ -9,7 +9,7 @@ description = "A reusable curses component library for terminal UI (TUI) applica
9
9
  readme = "README.md"
10
10
  license = "MIT"
11
11
  authors = [{ name = "Cobus Nel" }]
12
- requires-python = ">=3.13"
12
+ requires-python = ">=3.8"
13
13
  dependencies = ["pyperclip"]
14
14
  classifiers = [
15
15
  "Programming Language :: Python :: 3",