curses-themes 0.1__py3-none-any.whl

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.
@@ -0,0 +1,110 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ curses-themes: Lightweight theme support for Python curses applications
4
+
5
+ Inspired by FlossWare curses-java, this library brings professional theme
6
+ support to Python's standard curses module with zero external dependencies.
7
+
8
+ Copyright (C) 2024 FlossWare
9
+
10
+ This program is free software: you can redistribute it and/or modify
11
+ it under the terms of the GNU General Public License as published by
12
+ the Free Software Foundation, either version 3 of the License, or
13
+ (at your option) any later version.
14
+
15
+ This program is distributed in the hope that it will be useful,
16
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
17
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
18
+ GNU General Public License for more details.
19
+
20
+ You should have received a copy of the GNU General Public License
21
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
22
+
23
+ Example:
24
+ Basic usage with built-in theme::
25
+
26
+ import curses
27
+ from curses_themes import ThemeManager
28
+
29
+ def main(stdscr):
30
+ # Load and apply a theme
31
+ theme = ThemeManager.load('dracula')
32
+ theme.apply(stdscr)
33
+
34
+ # Use semantic colors
35
+ stdscr.addstr(0, 0, "Success!", theme.colors.success)
36
+ stdscr.addstr(1, 0, "Error!", theme.colors.error)
37
+
38
+ # Draw themed boxes
39
+ theme.draw_box(stdscr, 3, 2, 10, 40, title="My Panel")
40
+
41
+ stdscr.refresh()
42
+ stdscr.getch()
43
+
44
+ if __name__ == "__main__":
45
+ curses.wrapper(main)
46
+
47
+ Creating a custom theme::
48
+
49
+ from curses_themes import Theme, ThemeManager
50
+
51
+ class MyTheme(Theme):
52
+ def __init__(self):
53
+ super().__init__(
54
+ name="My Theme",
55
+ description="A custom theme",
56
+ author="Your Name"
57
+ )
58
+
59
+ def get_color_map(self):
60
+ return {
61
+ 'background': (0, 0, 0),
62
+ 'foreground': (255, 255, 255),
63
+ 'primary': (0, 120, 215),
64
+ 'success': (16, 124, 16),
65
+ 'error': (232, 17, 35),
66
+ 'warning': (193, 156, 0),
67
+ 'info': (0, 120, 212),
68
+ 'accent': (142, 68, 173),
69
+ }
70
+
71
+ # Register and use
72
+ ThemeManager.register(MyTheme)
73
+ theme = ThemeManager.load('my-theme')
74
+ """
75
+
76
+ from .colors import ColorManager
77
+ from .manager import ThemeManager
78
+ from .theme import ColorPair, ComponentColors, SemanticColors, Theme
79
+ from .themes import (
80
+ DarkTheme,
81
+ DBase3Theme,
82
+ DBase4Theme,
83
+ DefaultTheme,
84
+ DOSTheme,
85
+ LightTheme,
86
+ TI994ATheme,
87
+ TRS80Theme,
88
+ )
89
+
90
+ __version__ = "0.1"
91
+ __author__ = "FlossWare"
92
+ __license__ = "GPL-3.0"
93
+
94
+ __all__ = [
95
+ "ColorManager",
96
+ "ColorPair",
97
+ "ComponentColors",
98
+ "DBase3Theme",
99
+ "DBase4Theme",
100
+ "DOSTheme",
101
+ "DarkTheme",
102
+ "DefaultTheme",
103
+ "LightTheme",
104
+ "SemanticColors",
105
+ "TI994ATheme",
106
+ "TRS80Theme",
107
+ "Theme",
108
+ "ThemeManager",
109
+ "__version__",
110
+ ]
@@ -0,0 +1,324 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Color management and terminal capability detection for curses themes.
4
+
5
+ This module handles the complexities of terminal color support, including
6
+ detection of 8/16/256 color capabilities and RGB-to-palette conversion.
7
+
8
+ Copyright (C) 2024 FlossWare
9
+
10
+ This program is free software: you can redistribute it and/or modify
11
+ it under the terms of the GNU General Public License as published by
12
+ the Free Software Foundation, either version 3 of the License, or
13
+ (at your option) any later version.
14
+
15
+ This program is distributed in the hope that it will be useful,
16
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
17
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
18
+ GNU General Public License for more details.
19
+
20
+ You should have received a copy of the GNU General Public License
21
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
22
+ """
23
+
24
+ import curses
25
+ from typing import Optional
26
+
27
+ from .theme import ComponentColors, SemanticColors
28
+
29
+
30
+ class ColorManager:
31
+ """
32
+ Manages color initialization and terminal capability detection.
33
+
34
+ This class handles the complexities of working with different terminal
35
+ color capabilities (8, 16, or 256 colors) and converts RGB values to
36
+ appropriate curses color pairs.
37
+
38
+ Example:
39
+ ```python
40
+ def main(stdscr):
41
+ manager = ColorManager(stdscr)
42
+ colors = manager.initialize_theme(my_theme)
43
+ stdscr.addstr(0, 0, "Hello", curses.color_pair(colors.primary))
44
+ ```
45
+
46
+ Attributes:
47
+ stdscr: The curses window object
48
+ color_count: Number of colors supported (8, 16, or 256)
49
+ """
50
+
51
+ # Standard 8-color palette (ANSI colors)
52
+ BASIC_COLORS = {
53
+ curses.COLOR_BLACK: (0, 0, 0),
54
+ curses.COLOR_RED: (205, 0, 0),
55
+ curses.COLOR_GREEN: (0, 205, 0),
56
+ curses.COLOR_YELLOW: (205, 205, 0),
57
+ curses.COLOR_BLUE: (0, 0, 238),
58
+ curses.COLOR_MAGENTA: (205, 0, 205),
59
+ curses.COLOR_CYAN: (0, 205, 205),
60
+ curses.COLOR_WHITE: (229, 229, 229),
61
+ }
62
+
63
+ # Next color pair number to allocate
64
+ _next_pair = 1
65
+
66
+ def __init__(self, stdscr):
67
+ """
68
+ Initialize color manager for a curses screen.
69
+
70
+ Args:
71
+ stdscr: Curses window object (typically from curses.wrapper)
72
+
73
+ Raises:
74
+ RuntimeError: If the terminal doesn't support colors
75
+ """
76
+ self.stdscr = stdscr
77
+
78
+ if not curses.has_colors():
79
+ raise RuntimeError(
80
+ "Terminal does not support colors. "
81
+ "Please use a color-capable terminal emulator."
82
+ )
83
+
84
+ curses.start_color()
85
+ curses.use_default_colors()
86
+
87
+ self.color_count = self._detect_color_capability()
88
+
89
+ def _detect_color_capability(self) -> int:
90
+ """
91
+ Detect terminal color support level.
92
+
93
+ Returns:
94
+ Number of colors supported: 8, 16, or 256
95
+
96
+ Note:
97
+ Some terminals report more colors than they actually support well.
98
+ This method provides conservative detection.
99
+ """
100
+ max_colors = curses.COLORS
101
+
102
+ if max_colors >= 256:
103
+ return 256
104
+ elif max_colors >= 16:
105
+ return 16
106
+ else:
107
+ return 8
108
+
109
+ def _rgb_to_curses_color(self, r: int, g: int, b: int) -> int:
110
+ """
111
+ Convert RGB values to a curses color number.
112
+
113
+ Adapts to terminal capabilities by mapping to 256-color palette,
114
+ 16-color palette, or 8-color palette as appropriate.
115
+
116
+ Args:
117
+ r: Red component (0-255)
118
+ g: Green component (0-255)
119
+ b: Blue component (0-255)
120
+
121
+ Returns:
122
+ Curses color number appropriate for terminal capability
123
+ """
124
+ if self.color_count >= 256:
125
+ return self._rgb_to_256(r, g, b)
126
+ else:
127
+ return self._rgb_to_basic(r, g, b)
128
+
129
+ def _rgb_to_256(self, r: int, g: int, b: int) -> int:
130
+ """
131
+ Map RGB to 256-color palette.
132
+
133
+ Uses the standard xterm 256-color palette:
134
+ - Colors 0-15: System colors
135
+ - Colors 16-231: 6x6x6 RGB cube
136
+ - Colors 232-255: Grayscale ramp
137
+
138
+ Args:
139
+ r: Red component (0-255)
140
+ g: Green component (0-255)
141
+ b: Blue component (0-255)
142
+
143
+ Returns:
144
+ Color number in range 0-255
145
+ """
146
+ # Check if it's a grayscale color
147
+ if r == g == b:
148
+ # Use grayscale ramp (232-255)
149
+ if r < 8:
150
+ return 16 # Black
151
+ if r > 247:
152
+ return 231 # White
153
+ return 232 + (r - 8) // 10
154
+
155
+ # Map to 6x6x6 RGB cube (16-231)
156
+ # Each component mapped to 0-5
157
+ r_index = (r * 6) // 256
158
+ g_index = (g * 6) // 256
159
+ b_index = (b * 6) // 256
160
+
161
+ return 16 + (36 * r_index) + (6 * g_index) + b_index
162
+
163
+ def _rgb_to_basic(self, r: int, g: int, b: int) -> int:
164
+ """
165
+ Map RGB to 8 or 16 basic ANSI colors.
166
+
167
+ Finds the closest match in the basic color palette using
168
+ simple Euclidean distance in RGB space.
169
+
170
+ Args:
171
+ r: Red component (0-255)
172
+ g: Green component (0-255)
173
+ b: Blue component (0-255)
174
+
175
+ Returns:
176
+ Basic curses color constant (COLOR_BLACK, COLOR_RED, etc.)
177
+ """
178
+ min_distance = float("inf")
179
+ closest_color = curses.COLOR_WHITE
180
+
181
+ for color, (cr, cg, cb) in self.BASIC_COLORS.items():
182
+ # Euclidean distance in RGB space
183
+ distance = ((r - cr) ** 2 + (g - cg) ** 2 + (b - cb) ** 2) ** 0.5
184
+
185
+ if distance < min_distance:
186
+ min_distance = distance
187
+ closest_color = color
188
+
189
+ return closest_color
190
+
191
+ def _init_color_pair(
192
+ self,
193
+ fg_rgb: tuple[int, int, int],
194
+ bg_rgb: Optional[tuple[int, int, int]] = None,
195
+ ) -> int:
196
+ """
197
+ Initialize a curses color pair from RGB values.
198
+
199
+ Args:
200
+ fg_rgb: Foreground RGB tuple (R, G, B)
201
+ bg_rgb: Background RGB tuple, or None for default background
202
+
203
+ Returns:
204
+ Color pair number that can be used with curses.color_pair()
205
+ """
206
+ fg_color = self._rgb_to_curses_color(*fg_rgb)
207
+
208
+ if bg_rgb is None:
209
+ bg_color = -1 # Use default background
210
+ else:
211
+ bg_color = self._rgb_to_curses_color(*bg_rgb)
212
+
213
+ pair_num = ColorManager._next_pair
214
+ ColorManager._next_pair += 1
215
+
216
+ # Ensure we don't exceed curses color pair limit
217
+ if pair_num >= curses.COLOR_PAIRS:
218
+ raise RuntimeError(
219
+ f"Exceeded maximum color pairs ({curses.COLOR_PAIRS}). "
220
+ "Too many themes or colors in use."
221
+ )
222
+
223
+ try:
224
+ curses.init_pair(pair_num, fg_color, bg_color)
225
+ except curses.error as e:
226
+ raise RuntimeError(
227
+ f"Failed to initialize color pair {pair_num} "
228
+ f"(fg={fg_color}, bg={bg_color}): {e}"
229
+ )
230
+
231
+ return pair_num
232
+
233
+ def initialize_theme(self, theme) -> tuple[SemanticColors, ComponentColors]:
234
+ """
235
+ Initialize all color pairs for a theme.
236
+
237
+ Converts the theme's RGB color map and component colors to curses
238
+ color pairs appropriate for the terminal's capabilities.
239
+
240
+ Args:
241
+ theme: Theme instance with get_color_map() and component methods
242
+
243
+ Returns:
244
+ Tuple of (SemanticColors, ComponentColors) with initialized color pair numbers
245
+
246
+ Raises:
247
+ ValueError: If color map is missing required keys
248
+ RuntimeError: If color initialization fails
249
+ """
250
+ color_map = theme.get_color_map()
251
+
252
+ # Validate required colors
253
+ required_colors = {
254
+ "background",
255
+ "foreground",
256
+ "primary",
257
+ "success",
258
+ "error",
259
+ "warning",
260
+ "info",
261
+ "accent",
262
+ }
263
+ missing = required_colors - set(color_map.keys())
264
+ if missing:
265
+ raise ValueError(f"Theme '{theme.name}' missing required colors: {missing}")
266
+
267
+ # Get background RGB for all pairs
268
+ bg_rgb = color_map["background"]
269
+
270
+ # Initialize color pairs for each semantic color
271
+ # Background pair uses default background for transparency
272
+ background_pair = self._init_color_pair(color_map["foreground"], bg_rgb)
273
+
274
+ # All other colors use the theme's background
275
+ semantic_colors = SemanticColors(
276
+ primary=self._init_color_pair(color_map["primary"], bg_rgb),
277
+ success=self._init_color_pair(color_map["success"], bg_rgb),
278
+ error=self._init_color_pair(color_map["error"], bg_rgb),
279
+ warning=self._init_color_pair(color_map["warning"], bg_rgb),
280
+ info=self._init_color_pair(color_map["info"], bg_rgb),
281
+ background=background_pair,
282
+ foreground=self._init_color_pair(color_map["foreground"], bg_rgb),
283
+ accent=self._init_color_pair(color_map["accent"], bg_rgb),
284
+ )
285
+
286
+ # Initialize component-based color pairs from theme methods
287
+ component_colors = ComponentColors(
288
+ background=self._init_color_pair_from_colorpair(theme.get_background()),
289
+ button=self._init_color_pair_from_colorpair(theme.get_button()),
290
+ button_focused=self._init_color_pair_from_colorpair(
291
+ theme.get_button_focused()
292
+ ),
293
+ text_input=self._init_color_pair_from_colorpair(theme.get_text_input()),
294
+ border=self._init_color_pair_from_colorpair(theme.get_border()),
295
+ selection=self._init_color_pair_from_colorpair(theme.get_selection()),
296
+ disabled=self._init_color_pair_from_colorpair(theme.get_disabled()),
297
+ )
298
+
299
+ return semantic_colors, component_colors
300
+
301
+ def _init_color_pair_from_colorpair(self, color_pair) -> int:
302
+ """
303
+ Initialize a curses color pair from a ColorPair object.
304
+
305
+ Args:
306
+ color_pair: ColorPair with foreground and background RGB tuples
307
+
308
+ Returns:
309
+ Color pair number that can be used with curses.color_pair()
310
+ """
311
+ return self._init_color_pair(color_pair.foreground, color_pair.background)
312
+
313
+ def reset(self) -> None:
314
+ """
315
+ Reset color pair counter.
316
+
317
+ This is primarily for testing. In normal use, color pairs persist
318
+ for the lifetime of the curses session.
319
+ """
320
+ ColorManager._next_pair = 1
321
+
322
+ def __repr__(self) -> str:
323
+ """String representation for debugging."""
324
+ return f"ColorManager(colors={self.color_count})"