Coverage for src/glomph/terminal.py: 0%
114 statements
« prev ^ index » next coverage.py v7.10.7, created at 2025-10-06 00:21 +0800
« prev ^ index » next coverage.py v7.10.7, created at 2025-10-06 00:21 +0800
1"""Terminal abstraction layer using curses."""
3import curses
4from collections.abc import Generator
5from contextlib import contextmanager
8class Terminal:
9 """Curses-based terminal abstraction for the game."""
11 def __init__(self) -> None:
12 """Initialize terminal interface."""
13 self.stdscr: curses.window | None = None
14 self.colors_initialized = False
16 def init_colors(self) -> None:
17 """Initialize color pairs if terminal supports colors."""
18 if not curses.has_colors():
19 return
21 curses.start_color()
22 curses.use_default_colors()
24 # Define common color pairs (foreground, background)
25 curses.init_pair(1, curses.COLOR_BLACK, -1) # Black on default
26 curses.init_pair(2, curses.COLOR_RED, -1) # Red on default
27 curses.init_pair(3, curses.COLOR_GREEN, -1) # Green on default
28 curses.init_pair(4, curses.COLOR_YELLOW, -1) # Yellow on default
29 curses.init_pair(5, curses.COLOR_BLUE, -1) # Blue on default
30 curses.init_pair(6, curses.COLOR_MAGENTA, -1) # Magenta on default
31 curses.init_pair(7, curses.COLOR_CYAN, -1) # Cyan on default
32 curses.init_pair(8, curses.COLOR_WHITE, -1) # White on default
34 self.colors_initialized = True
36 @contextmanager
37 def screen(self) -> Generator[curses.window, None, None]:
38 """Context manager for curses screen initialization."""
39 try:
40 self.stdscr = curses.initscr()
41 curses.noecho() # Don't echo keys
42 curses.cbreak() # React to keys instantly
43 self.stdscr.keypad(True) # Enable special keys
44 curses.curs_set(0) # Hide cursor
46 self.init_colors()
48 yield self.stdscr
50 finally:
51 if self.stdscr:
52 self.stdscr.keypad(False)
53 curses.echo()
54 curses.nocbreak()
55 curses.endwin()
57 def get_screen_size(self) -> tuple[int, int]:
58 """Get terminal screen size (height, width)."""
59 if not self.stdscr:
60 return (24, 80) # Default fallback
61 height, width = self.stdscr.getmaxyx()
62 return height, width
64 def draw_char(self, y: int, x: int, char: str, color_pair: int = 0) -> None:
65 """Draw a character at position (y, x) with optional color."""
66 if not self.stdscr:
67 return
69 try:
70 if self.colors_initialized and color_pair > 0:
71 self.stdscr.addch(y, x, char, curses.color_pair(color_pair))
72 else:
73 self.stdscr.addch(y, x, char)
74 except curses.error:
75 # Ignore drawing errors (out of bounds, etc.)
76 pass
78 def draw_text(self, y: int, x: int, text: str, color_pair: int = 0) -> None:
79 """Draw text at position (y, x) with optional color."""
80 if not self.stdscr:
81 return
83 try:
84 if self.colors_initialized and color_pair > 0:
85 self.stdscr.addstr(y, x, text, curses.color_pair(color_pair))
86 else:
87 self.stdscr.addstr(y, x, text)
88 except curses.error:
89 # Ignore drawing errors
90 pass
92 def clear(self) -> None:
93 """Clear the screen."""
94 if self.stdscr:
95 self.stdscr.clear()
97 def refresh(self) -> None:
98 """Refresh the screen to show changes."""
99 if self.stdscr:
100 self.stdscr.refresh()
102 def get_input(self, timeout_ms: int | None = None) -> int | None:
103 """Get input character, optionally with timeout."""
104 if not self.stdscr:
105 return None
107 if timeout_ms is not None:
108 self.stdscr.timeout(timeout_ms)
109 else:
110 self.stdscr.timeout(-1) # Blocking
112 try:
113 return self.stdscr.getch()
114 except (curses.error, KeyboardInterrupt):
115 return None
117 def wait_for_key(self) -> int:
118 """Wait for and return a key press."""
119 while True:
120 key = self.get_input()
121 if key is not None:
122 return key
124 @staticmethod
125 def key_name(key: int) -> str:
126 """Get the name of a key code."""
127 return curses.keyname(key).decode('utf-8')
129 def get_key(self) -> str | None:
130 """Get a key press and return a normalized key name."""
131 key = self.get_input()
132 if key is None:
133 return None
135 # Handle special keys
136 if key == curses.KEY_UP:
137 return "up"
138 elif key == curses.KEY_DOWN:
139 return "down"
140 elif key == curses.KEY_LEFT:
141 return "left"
142 elif key == curses.KEY_RIGHT:
143 return "right"
144 elif key == curses.KEY_ENTER or key == 10 or key == 13:
145 return "enter"
146 elif key == 27: # ESC
147 return "escape"
148 elif key == 32: # Space
149 return "space"
150 elif key == curses.KEY_BACKSPACE or key == 127:
151 return "backspace"
152 elif key >= 32 and key <= 126: # Printable ASCII
153 return chr(key)
154 else:
155 return f"key_{key}"
157 def wait_for_keypress(self) -> str:
158 """Wait for a key press and return normalized key name."""
159 while True:
160 key = self.get_key()
161 if key is not None:
162 return key
165# Global terminal instance
166_terminal = Terminal()
169def get_terminal() -> Terminal:
170 """Get the global terminal instance."""
171 return _terminal