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

1"""Terminal abstraction layer using curses.""" 

2 

3import curses 

4from collections.abc import Generator 

5from contextlib import contextmanager 

6 

7 

8class Terminal: 

9 """Curses-based terminal abstraction for the game.""" 

10 

11 def __init__(self) -> None: 

12 """Initialize terminal interface.""" 

13 self.stdscr: curses.window | None = None 

14 self.colors_initialized = False 

15 

16 def init_colors(self) -> None: 

17 """Initialize color pairs if terminal supports colors.""" 

18 if not curses.has_colors(): 

19 return 

20 

21 curses.start_color() 

22 curses.use_default_colors() 

23 

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 

33 

34 self.colors_initialized = True 

35 

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 

45 

46 self.init_colors() 

47 

48 yield self.stdscr 

49 

50 finally: 

51 if self.stdscr: 

52 self.stdscr.keypad(False) 

53 curses.echo() 

54 curses.nocbreak() 

55 curses.endwin() 

56 

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 

63 

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 

68 

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 

77 

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 

82 

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 

91 

92 def clear(self) -> None: 

93 """Clear the screen.""" 

94 if self.stdscr: 

95 self.stdscr.clear() 

96 

97 def refresh(self) -> None: 

98 """Refresh the screen to show changes.""" 

99 if self.stdscr: 

100 self.stdscr.refresh() 

101 

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 

106 

107 if timeout_ms is not None: 

108 self.stdscr.timeout(timeout_ms) 

109 else: 

110 self.stdscr.timeout(-1) # Blocking 

111 

112 try: 

113 return self.stdscr.getch() 

114 except (curses.error, KeyboardInterrupt): 

115 return None 

116 

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 

123 

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') 

128 

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 

134 

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}" 

156 

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 

163 

164 

165# Global terminal instance 

166_terminal = Terminal() 

167 

168 

169def get_terminal() -> Terminal: 

170 """Get the global terminal instance.""" 

171 return _terminal