borse 0.1.0__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.
borse/game.py ADDED
@@ -0,0 +1,472 @@
1
+ """Curses-based game UI for Borse."""
2
+
3
+ import contextlib
4
+ import curses
5
+ from collections.abc import Callable
6
+ from enum import Enum
7
+
8
+ from borse import a1z26, braille, morse, semaphore
9
+ from borse.config import load_config, save_config
10
+ from borse.progress import load_progress, save_progress
11
+ from borse.words import get_random_word_or_letter
12
+
13
+
14
+ class GameMode(Enum):
15
+ """Game mode enumeration."""
16
+
17
+ BRAILLE = "braille"
18
+ MORSE = "morse"
19
+ SEMAPHORE = "semaphore"
20
+ A1Z26 = "a1z26"
21
+
22
+
23
+ class SettingsMode(Enum):
24
+ SETTINGS = "settings"
25
+
26
+
27
+ # Map modes to their display functions
28
+ MODE_DISPLAY_FUNCS: dict[GameMode, Callable[[str], list[str]]] = {
29
+ GameMode.BRAILLE: braille.get_display_lines,
30
+ GameMode.MORSE: morse.get_display_lines,
31
+ GameMode.SEMAPHORE: semaphore.get_display_lines,
32
+ GameMode.A1Z26: a1z26.get_display_lines,
33
+ }
34
+
35
+ MODE_NAMES: dict[GameMode, str] = {
36
+ GameMode.BRAILLE: "Braille",
37
+ GameMode.MORSE: "Morse Code",
38
+ GameMode.SEMAPHORE: "Semaphore",
39
+ GameMode.A1Z26: "A1Z26",
40
+ }
41
+
42
+ # Keyboard shortcuts for modes
43
+ MODE_SHORTCUTS: dict[str, GameMode] = {
44
+ "b": GameMode.BRAILLE,
45
+ "B": GameMode.BRAILLE,
46
+ "m": GameMode.MORSE,
47
+ "M": GameMode.MORSE,
48
+ "s": GameMode.SEMAPHORE,
49
+ "S": GameMode.SEMAPHORE,
50
+ "a": GameMode.A1Z26,
51
+ "A": GameMode.A1Z26,
52
+ }
53
+
54
+
55
+ class Game:
56
+ """Main game class handling the curses UI."""
57
+
58
+ def __init__(self, stdscr: curses.window) -> None:
59
+ """Initialize the game.
60
+
61
+ Args:
62
+ stdscr: The curses standard screen window.
63
+ """
64
+ self.stdscr = stdscr
65
+ self.config = load_config()
66
+ self.progress = load_progress(self.config.progress_file)
67
+
68
+ # Setup curses
69
+ curses.curs_set(1) # Show cursor
70
+ curses.use_default_colors()
71
+ self.stdscr.keypad(True)
72
+
73
+ # Make ESC key respond instantly (reduce delay from 1000ms to 25ms)
74
+ curses.set_escdelay(25)
75
+
76
+ # Initialize color pairs if available
77
+ if curses.has_colors():
78
+ curses.start_color()
79
+ curses.init_pair(1, curses.COLOR_GREEN, -1) # Correct
80
+ curses.init_pair(2, curses.COLOR_YELLOW, -1) # Title
81
+ curses.init_pair(3, curses.COLOR_CYAN, -1) # Info
82
+
83
+ def draw_title(self, title: str) -> int:
84
+ """Draw a title at the top of the screen (left-aligned).
85
+
86
+ Args:
87
+ title: The title text.
88
+
89
+ Returns:
90
+ The next available row.
91
+ """
92
+ self.stdscr.clear()
93
+ try:
94
+ if curses.has_colors():
95
+ self.stdscr.attron(curses.color_pair(2) | curses.A_BOLD)
96
+ self.stdscr.addstr(1, 2, title)
97
+ if curses.has_colors():
98
+ self.stdscr.attroff(curses.color_pair(2) | curses.A_BOLD)
99
+ except curses.error:
100
+ pass
101
+ return 3
102
+
103
+ def show_menu(self) -> GameMode | SettingsMode | None:
104
+ """Show the main menu and get mode selection.
105
+
106
+ Returns:
107
+ Selected GameMode, "settings" for settings menu, or None to quit.
108
+ """
109
+ selected = 0
110
+ modes = list(GameMode)
111
+ # Menu items with keyboard shortcuts shown
112
+ menu_items = [
113
+ "[B] Braille",
114
+ "[M] Morse Code",
115
+ "[S] Semaphore",
116
+ "[A] A1Z26",
117
+ "[O] Options",
118
+ "[Q] Quit",
119
+ ]
120
+
121
+ while True:
122
+ row = self.draw_title("BORSE - Code Practice Game")
123
+ height, _ = self.stdscr.getmaxyx()
124
+
125
+ # Show today's progress
126
+ today = self.progress.get_today()
127
+ progress_text = (
128
+ f"Today: {today.total_words} words "
129
+ f"(B:{today.braille_words} M:{today.morse_words} "
130
+ f"S:{today.semaphore_words} A:{today.a1z26_words})"
131
+ )
132
+ try:
133
+ if curses.has_colors():
134
+ self.stdscr.attron(curses.color_pair(3))
135
+ self.stdscr.addstr(row, 2, progress_text)
136
+ if curses.has_colors():
137
+ self.stdscr.attroff(curses.color_pair(3))
138
+ except curses.error:
139
+ pass
140
+
141
+ row += 2
142
+
143
+ # Instructions
144
+ with contextlib.suppress(curses.error):
145
+ self.stdscr.addstr(row, 2, "Select a mode to practice:")
146
+ row += 2
147
+
148
+ # Menu items
149
+ for i, item in enumerate(menu_items):
150
+ try:
151
+ if i == selected:
152
+ self.stdscr.attron(curses.A_REVERSE)
153
+ self.stdscr.addstr(row + i, 4, f" {item} ")
154
+ if i == selected:
155
+ self.stdscr.attroff(curses.A_REVERSE)
156
+ except curses.error:
157
+ pass
158
+
159
+ # Navigation hints
160
+ with contextlib.suppress(curses.error):
161
+ hint_row = min(row + len(menu_items) + 2, height - 2)
162
+ self.stdscr.addstr(
163
+ hint_row, 2, "Use arrows + Enter, or press shortcut key."
164
+ )
165
+
166
+ self.stdscr.refresh()
167
+
168
+ key = self.stdscr.getch()
169
+
170
+ if key == curses.KEY_UP:
171
+ selected = (selected - 1) % len(menu_items)
172
+ elif key == curses.KEY_DOWN:
173
+ selected = (selected + 1) % len(menu_items)
174
+ elif key in (curses.KEY_ENTER, 10, 13):
175
+ if selected < len(modes):
176
+ return modes[selected]
177
+ elif selected == len(modes): # Settings
178
+ return SettingsMode.SETTINGS
179
+ return None # Quit
180
+ elif key == ord("q") or key == ord("Q"):
181
+ return None
182
+ elif key == ord("o") or key == ord("O"):
183
+ return SettingsMode.SETTINGS
184
+ else:
185
+ # Check for shortcut keys
186
+ try:
187
+ char = chr(key)
188
+ if char in MODE_SHORTCUTS:
189
+ return MODE_SHORTCUTS[char]
190
+ except (ValueError, OverflowError):
191
+ pass
192
+
193
+ def play_game(self, mode: GameMode) -> None:
194
+ """Play a game session in the given mode.
195
+
196
+ Args:
197
+ mode: The game mode to play.
198
+ """
199
+ display_func = MODE_DISPLAY_FUNCS[mode]
200
+ mode_name = MODE_NAMES[mode]
201
+ words_completed = 0
202
+ total_words = self.config.words_per_game
203
+ completed_words: list[str] = [] # Track completed words
204
+
205
+ while words_completed < total_words:
206
+ word = get_random_word_or_letter(self.config.single_letter_probability)
207
+ user_input = ""
208
+
209
+ while True:
210
+ row = self.draw_title(
211
+ f"{mode_name} - Word {words_completed + 1}/{total_words}"
212
+ )
213
+ height, _width = self.stdscr.getmaxyx()
214
+
215
+ # Display the encoded word
216
+ display_lines = display_func(word)
217
+ for i, line in enumerate(display_lines):
218
+ with contextlib.suppress(curses.error):
219
+ self.stdscr.addstr(row + i, 4, line)
220
+
221
+ row += len(display_lines) + 2
222
+
223
+ # Input prompt - show user input in UPPERCASE
224
+ input_row = row
225
+ input_start = 17
226
+ try:
227
+ self.stdscr.addstr(row, 2, "Type the word: ")
228
+ display_input = user_input.upper()
229
+ self.stdscr.addstr(row, input_start, display_input)
230
+
231
+ # Show correct characters in green
232
+ for i, char in enumerate(user_input):
233
+ if i < len(word) and char.lower() == word[i].lower():
234
+ if curses.has_colors():
235
+ self.stdscr.attron(curses.color_pair(1))
236
+ self.stdscr.addstr(row, input_start + i, char.upper())
237
+ if curses.has_colors():
238
+ self.stdscr.attroff(curses.color_pair(1))
239
+ except curses.error:
240
+ pass
241
+
242
+ row += 2
243
+
244
+ # Instructions
245
+ with contextlib.suppress(curses.error):
246
+ self.stdscr.addstr(row, 2, "Press Esc to return to menu")
247
+
248
+ row += 2
249
+
250
+ # Show completed words in green at the bottom
251
+ if completed_words:
252
+ try:
253
+ # Calculate available space
254
+ available_rows = height - row - 1
255
+ if available_rows > 0:
256
+ if curses.has_colors():
257
+ self.stdscr.attron(curses.color_pair(1))
258
+ self.stdscr.addstr(row, 2, "Completed: ")
259
+ if curses.has_colors():
260
+ self.stdscr.attroff(curses.color_pair(1))
261
+
262
+ # Join words with commas, fitting on available lines
263
+ words_str = ", ".join(w.upper() for w in completed_words)
264
+ if curses.has_colors():
265
+ self.stdscr.attron(curses.color_pair(1))
266
+ self.stdscr.addstr(row, 13, words_str[: _width - 15])
267
+ if curses.has_colors():
268
+ self.stdscr.attroff(curses.color_pair(1))
269
+ except curses.error:
270
+ pass
271
+
272
+ # Position cursor at the typing location
273
+ with contextlib.suppress(curses.error):
274
+ self.stdscr.move(input_row, input_start + len(user_input))
275
+
276
+ self.stdscr.refresh()
277
+
278
+ key = self.stdscr.getch()
279
+
280
+ if key == 27: # Escape
281
+ return
282
+ elif key in (curses.KEY_BACKSPACE, 127, 8):
283
+ user_input = user_input[:-1]
284
+ elif 32 <= key <= 126: # Printable characters
285
+ user_input += chr(key)
286
+
287
+ # Check if word matches
288
+ if user_input.lower() == word.lower():
289
+ words_completed += 1
290
+ completed_words.append(word)
291
+ self.progress.add_word(mode.value)
292
+ save_progress(self.progress, self.config.progress_file)
293
+ break
294
+
295
+ # Show completion message
296
+ self.show_completion(mode, words_completed, completed_words)
297
+
298
+ def show_settings(self) -> None:
299
+ """Show the settings menu for editing configuration."""
300
+ selected = 0
301
+ settings_items = [
302
+ "words_per_game",
303
+ "single_letter_probability",
304
+ ]
305
+ editing: int | None = None
306
+ edit_buffer = ""
307
+
308
+ while True:
309
+ row = self.draw_title("Settings")
310
+
311
+ # Instructions
312
+ with contextlib.suppress(curses.error):
313
+ self.stdscr.addstr(row, 2, "Edit game settings:")
314
+ row += 2
315
+
316
+ # Setting items
317
+ for i, setting in enumerate(settings_items):
318
+ try:
319
+ value = getattr(self.config, setting)
320
+ if setting == "single_letter_probability":
321
+ display_value = f"{value:.0%}"
322
+ else:
323
+ display_value = str(value)
324
+
325
+ if editing == i:
326
+ # Show edit mode
327
+ label = f" {setting}: "
328
+ self.stdscr.addstr(row + i, 4, label)
329
+ self.stdscr.attron(curses.A_UNDERLINE)
330
+ self.stdscr.addstr(row + i, 4 + len(label), edit_buffer + "_")
331
+ self.stdscr.attroff(curses.A_UNDERLINE)
332
+ else:
333
+ label = f" {setting}: {display_value} "
334
+ if i == selected:
335
+ self.stdscr.attron(curses.A_REVERSE)
336
+ self.stdscr.addstr(row + i, 4, label)
337
+ if i == selected:
338
+ self.stdscr.attroff(curses.A_REVERSE)
339
+ except curses.error:
340
+ pass
341
+
342
+ row += len(settings_items) + 2
343
+
344
+ # Navigation hints
345
+ with contextlib.suppress(curses.error):
346
+ if editing is not None:
347
+ self.stdscr.addstr(row, 2, "Enter: save, Esc: cancel")
348
+ else:
349
+ self.stdscr.addstr(row, 2, "Enter: edit, Esc: back to menu")
350
+
351
+ self.stdscr.refresh()
352
+
353
+ key = self.stdscr.getch()
354
+
355
+ if editing is not None:
356
+ # Edit mode
357
+ if key == 27: # Escape - cancel edit
358
+ editing = None
359
+ edit_buffer = ""
360
+ elif key in (curses.KEY_ENTER, 10, 13): # Enter - save
361
+ try:
362
+ setting = settings_items[editing]
363
+ if setting == "words_per_game":
364
+ new_value = int(edit_buffer)
365
+ if new_value > 0:
366
+ self.config.words_per_game = new_value
367
+ elif setting == "single_letter_probability":
368
+ # Accept both decimal (0.3) and percentage (30)
369
+ val = float(edit_buffer)
370
+ if val > 1:
371
+ val = val / 100
372
+ if 0 <= val <= 1:
373
+ self.config.single_letter_probability = val
374
+ save_config(self.config)
375
+ except ValueError:
376
+ pass # Invalid input, ignore
377
+ editing = None
378
+ edit_buffer = ""
379
+ elif key in (curses.KEY_BACKSPACE, 127, 8):
380
+ edit_buffer = edit_buffer[:-1]
381
+ elif 32 <= key <= 126: # Printable characters
382
+ edit_buffer += chr(key)
383
+ else:
384
+ # Navigation mode
385
+ if key == 27: # Escape - back to menu
386
+ return
387
+ elif key == curses.KEY_UP:
388
+ selected = (selected - 1) % len(settings_items)
389
+ elif key == curses.KEY_DOWN:
390
+ selected = (selected + 1) % len(settings_items)
391
+ elif key in (curses.KEY_ENTER, 10, 13):
392
+ editing = selected
393
+ # Pre-fill with current value
394
+ setting = settings_items[selected]
395
+ value = getattr(self.config, setting)
396
+ if setting == "single_letter_probability":
397
+ edit_buffer = str(int(value * 100))
398
+ else:
399
+ edit_buffer = str(value)
400
+
401
+ def show_completion(
402
+ self, mode: GameMode, words_completed: int, completed_words: list[str]
403
+ ) -> None:
404
+ """Show completion screen.
405
+
406
+ Args:
407
+ mode: The game mode that was played.
408
+ words_completed: Number of words completed.
409
+ completed_words: List of completed words.
410
+ """
411
+ row = self.draw_title("Session Complete!")
412
+ height, width = self.stdscr.getmaxyx()
413
+
414
+ try:
415
+ if curses.has_colors():
416
+ self.stdscr.attron(curses.color_pair(1))
417
+ self.stdscr.addstr(
418
+ row, 2, f"You completed {words_completed} {MODE_NAMES[mode]} words!"
419
+ )
420
+ if curses.has_colors():
421
+ self.stdscr.attroff(curses.color_pair(1))
422
+
423
+ row += 2
424
+ today = self.progress.get_today()
425
+ self.stdscr.addstr(row, 2, f"Today's total: {today.total_words} words")
426
+
427
+ row += 2
428
+
429
+ # Show all completed words
430
+ if completed_words:
431
+ self.stdscr.addstr(row, 2, "Words completed:")
432
+ row += 1
433
+ if curses.has_colors():
434
+ self.stdscr.attron(curses.color_pair(1))
435
+ words_str = ", ".join(w.upper() for w in completed_words)
436
+ # Wrap if needed
437
+ max_width = width - 4
438
+ for i in range(0, len(words_str), max_width):
439
+ if row < height - 3:
440
+ self.stdscr.addstr(row, 2, words_str[i : i + max_width])
441
+ row += 1
442
+ if curses.has_colors():
443
+ self.stdscr.attroff(curses.color_pair(1))
444
+
445
+ row = max(row + 1, height - 3)
446
+ self.stdscr.addstr(min(row, height - 2), 2, "Press any key to continue...")
447
+ except curses.error:
448
+ pass
449
+
450
+ self.stdscr.refresh()
451
+ self.stdscr.getch()
452
+
453
+ def run(self) -> None:
454
+ """Run the main game loop."""
455
+ while True:
456
+ result = self.show_menu()
457
+ if result is None:
458
+ break
459
+ elif isinstance(result, SettingsMode):
460
+ self.show_settings()
461
+ else:
462
+ self.play_game(result)
463
+
464
+
465
+ def run_game(stdscr: curses.window) -> None:
466
+ """Entry point for curses wrapper.
467
+
468
+ Args:
469
+ stdscr: The curses standard screen window.
470
+ """
471
+ game = Game(stdscr)
472
+ game.run()
borse/main.py ADDED
@@ -0,0 +1,27 @@
1
+ """Main entry point for Borse."""
2
+
3
+ import curses
4
+ import sys
5
+
6
+ from borse.game import run_game
7
+
8
+
9
+ def main() -> int:
10
+ """Run the Borse game.
11
+
12
+ Returns:
13
+ Exit code (0 for success).
14
+ """
15
+ try:
16
+ curses.wrapper(run_game)
17
+ return 0
18
+ except KeyboardInterrupt:
19
+ return 0
20
+ except curses.error as e:
21
+ print(f"Terminal error: {e}", file=sys.stderr)
22
+ print("Please ensure your terminal supports curses.", file=sys.stderr)
23
+ return 1
24
+
25
+
26
+ if __name__ == "__main__":
27
+ sys.exit(main())
borse/morse.py ADDED
@@ -0,0 +1,87 @@
1
+ """Morse code encoding module."""
2
+
3
+ # Morse code mapping for letters and numbers
4
+ MORSE_CODE: dict[str, str] = {
5
+ "A": ".-",
6
+ "B": "-...",
7
+ "C": "-.-.",
8
+ "D": "-..",
9
+ "E": ".",
10
+ "F": "..-.",
11
+ "G": "--.",
12
+ "H": "....",
13
+ "I": "..",
14
+ "J": ".---",
15
+ "K": "-.-",
16
+ "L": ".-..",
17
+ "M": "--",
18
+ "N": "-.",
19
+ "O": "---",
20
+ "P": ".--.",
21
+ "Q": "--.-",
22
+ "R": ".-.",
23
+ "S": "...",
24
+ "T": "-",
25
+ "U": "..-",
26
+ "V": "...-",
27
+ "W": ".--",
28
+ "X": "-..-",
29
+ "Y": "-.--",
30
+ "Z": "--..",
31
+ "0": "-----",
32
+ "1": ".----",
33
+ "2": "..---",
34
+ "3": "...--",
35
+ "4": "....-",
36
+ "5": ".....",
37
+ "6": "-....",
38
+ "7": "--...",
39
+ "8": "---..",
40
+ "9": "----.",
41
+ }
42
+
43
+ # Use Unicode dot and dash for nicer display
44
+ DOT = "●"
45
+ DASH = "━"
46
+
47
+
48
+ def encode_char(char: str) -> str:
49
+ """Encode a single character to Morse code.
50
+
51
+ Args:
52
+ char: A single character to encode.
53
+
54
+ Returns:
55
+ The Morse code representation using ● for dot and ━ for dash.
56
+ """
57
+ upper = char.upper()
58
+ if upper not in MORSE_CODE:
59
+ return ""
60
+ morse = MORSE_CODE[upper]
61
+ # Add space between each dot/dash within the letter
62
+ symbols = [DOT if c == "." else DASH for c in morse]
63
+ return " ".join(symbols)
64
+
65
+
66
+ def encode_word(word: str) -> str:
67
+ """Encode a word to Morse code.
68
+
69
+ Args:
70
+ word: The word to encode.
71
+
72
+ Returns:
73
+ The Morse code representation with spaces between letters.
74
+ """
75
+ return " ".join(encode_char(c) for c in word if c.upper() in MORSE_CODE)
76
+
77
+
78
+ def get_display_lines(word: str) -> list[str]:
79
+ """Get the display lines for a word in Morse code.
80
+
81
+ Args:
82
+ word: The word to encode.
83
+
84
+ Returns:
85
+ A list containing a single line with the Morse code.
86
+ """
87
+ return [encode_word(word)]