33from __future__ import annotations
44
55from collections .abc import Callable
6- from typing import Final , Self
6+ from typing import Final , Protocol , Self
77
88import attrs
99import tcod .console
1515import game .world_tools
1616from game .components import Gold , Graphic , Position
1717from game .message_tools import report
18- from game .rendering import LogRenderer
18+ from game .rendering import LogRenderer , draw_previous_state
1919from game .state import Pop , Push , Reset , State , StateResult
2020from game .tags import IsItem , IsPlayer
2121
@@ -88,13 +88,81 @@ def on_draw(self, console: tcod.console.Console) -> None:
8888 LogRenderer .init (g .world , console .width , 5 ).render ().blit (dest = console , dest_x = 0 , dest_y = console .height - 5 )
8989
9090
91+ class MenuItem (Protocol ):
92+ """Menu item protocol."""
93+
94+ __slots__ = ()
95+
96+ @property
97+ def label (self ) -> str :
98+ """Label for the menu item."""
99+
100+ def on_event (self , event : tcod .event .Event ) -> StateResult :
101+ """Handle events passed to the menu item."""
102+
103+
91104@attrs .define ()
92- class MenuItem :
105+ class SelectItem ( MenuItem ) :
93106 """Clickable menu item."""
94107
95108 label : str
96109 callback : Callable [[], StateResult ]
97110
111+ def on_event (self , event : tcod .event .Event ) -> StateResult :
112+ """Handle events selecting this item."""
113+ match event :
114+ case tcod .event .KeyDown (sym = sym ) if sym in {KeySym .RETURN , KeySym .RETURN2 , KeySym .KP_ENTER }:
115+ return self .callback ()
116+ case tcod .event .MouseButtonUp (button = tcod .event .MouseButton .LEFT ):
117+ return self .callback ()
118+ case _:
119+ return None
120+
121+
122+ @attrs .define ()
123+ class IntItem (MenuItem ):
124+ """Numbered item."""
125+
126+ format : str = "{}"
127+ value : int = 0
128+ min_value : int | None = 0
129+ max_value : int | None = None
130+ on_changed_callback : Callable [[int ], None ] | None = None
131+
132+ @property
133+ def label (self ) -> str :
134+ """Return a label including the current value."""
135+ return self .format .format (self .value )
136+
137+ def set_value (self , value : int | str ) -> None :
138+ """Set and clamp the value."""
139+ if isinstance (value , str ):
140+ try :
141+ value = int (value )
142+ except ValueError :
143+ return
144+ if self .min_value is not None :
145+ value = max (value , self .min_value )
146+ if self .max_value is not None :
147+ value = min (value , self .max_value )
148+ self .value = value
149+ if self .on_changed_callback is not None :
150+ self .on_changed_callback (value )
151+
152+ def on_event (self , event : tcod .event .Event ) -> StateResult :
153+ """Handle events for updating the current value."""
154+ match event :
155+ case tcod .event .KeyDown (sym = sym ) if sym in DIRECTION_KEYS :
156+ dx , dy = DIRECTION_KEYS [sym ]
157+ self .set_value (self .value + dx )
158+ return None
159+ case tcod .event .KeyDown (sym = sym ) if sym in {KeySym .RETURN , KeySym .RETURN2 , KeySym .KP_ENTER }:
160+ return Push (TextFieldWindow (buffer = str (self .value ), on_done_callback = self .set_value ))
161+ case tcod .event .MouseButtonUp (button = tcod .event .MouseButton .LEFT ):
162+ return Push (TextFieldWindow (buffer = str (self .value ), on_done_callback = self .set_value ))
163+ case _:
164+ return None
165+
98166
99167@attrs .define (eq = False )
100168class ListMenu (State ):
@@ -105,15 +173,15 @@ class ListMenu(State):
105173 x : int = 0
106174 y : int = 0
107175
108- def on_event (self , event : tcod .event .Event ) -> StateResult : # noqa: PLR0911
176+ def on_event (self , event : tcod .event .Event ) -> StateResult :
109177 """Handle events for menus."""
110178 match event :
111179 case tcod .event .Quit ():
112180 raise SystemExit ()
113181 case tcod .event .KeyDown (sym = sym ) if sym in DIRECTION_KEYS :
114182 dx , dy = DIRECTION_KEYS [sym ]
115183 if dx != 0 or dy == 0 :
116- return None
184+ return self . activate_selected ( event )
117185 if self .selected is not None :
118186 self .selected += dy
119187 self .selected %= len (self .items )
@@ -124,21 +192,17 @@ def on_event(self, event: tcod.event.Event) -> StateResult: # noqa: PLR0911
124192 y -= self .y
125193 self .selected = y if 0 <= y < len (self .items ) else None
126194 return None
127- case tcod .event .KeyDown (sym = KeySym .RETURN ):
128- return self .activate_selected ()
129- case tcod .event .MouseButtonUp (button = tcod .event .MouseButton .LEFT ):
130- return self .activate_selected ()
131195 case tcod .event .KeyDown (sym = KeySym .ESCAPE ):
132196 return self .on_cancel ()
133197 case tcod .event .MouseButtonUp (button = tcod .event .MouseButton .RIGHT ):
134198 return self .on_cancel ()
135199 case _:
136- return None
200+ return self . activate_selected ( event )
137201
138- def activate_selected (self ) -> StateResult :
202+ def activate_selected (self , event : tcod . event . Event ) -> StateResult :
139203 """Call the selected menu items callback."""
140204 if self .selected is not None :
141- return self .items [self .selected ].callback ( )
205+ return self .items [self .selected ].on_event ( event )
142206 return None
143207
144208 def on_cancel (self ) -> StateResult :
@@ -164,6 +228,54 @@ def on_draw(self, console: tcod.console.Console) -> None:
164228 )
165229
166230
231+ @attrs .define (eq = False )
232+ class TextFieldWindow (State ):
233+ """Modal user-editable text field window."""
234+
235+ buffer : str
236+ on_done_callback : Callable [[str ], None ]
237+
238+ x : int = 5
239+ y : int = 5
240+ width : int = 24
241+
242+ def on_done (self ) -> StateResult :
243+ """Called when the editing is finished."""
244+ self .on_done_callback (self .buffer )
245+ return Pop ()
246+
247+ def on_event (self , event : tcod .event .Event ) -> StateResult :
248+ """Handle events for text editing."""
249+ match event :
250+ case tcod .event .Quit ():
251+ raise SystemExit ()
252+ case tcod .event .KeyDown (sym = sym ) if sym in {KeySym .RETURN , KeySym .RETURN2 , KeySym .KP_ENTER }:
253+ return self .on_done ()
254+ case tcod .event .KeyDown (sym = KeySym .ESCAPE ):
255+ return self .on_done ()
256+ case tcod .event .KeyDown (sym = KeySym .BACKSPACE ):
257+ self .buffer = self .buffer [:- 1 ]
258+ return None
259+ case tcod .event .TextInput (text = text ):
260+ self .buffer += text
261+ return None
262+ case _:
263+ return None
264+
265+ def on_draw (self , console : tcod .console .Console ) -> None :
266+ """Draw the text buffer."""
267+ draw_previous_state (console , self )
268+
269+ console .draw_frame (self .x , self .y , self .width + 2 , 3 )
270+ console .print (
271+ self .x + 1 ,
272+ self .y + 1 ,
273+ self .buffer + "_" ,
274+ fg = (255 , 255 , 255 ),
275+ bg = (0 , 0 , 0 ),
276+ )
277+
278+
167279class MainMenu (ListMenu ):
168280 """Main/escape menu."""
169281
@@ -172,11 +284,20 @@ class MainMenu(ListMenu):
172284 def __init__ (self ) -> None :
173285 """Initialize the main menu."""
174286 items = [
175- MenuItem ("New game" , self .new_game ),
176- MenuItem ("Quit" , self .quit ),
287+ SelectItem ("New game" , self .new_game ),
288+ SelectItem ("Quit" , self .quit ),
289+ IntItem (
290+ "Console width: {}" ,
291+ g .config .console .columns ,
292+ min_value = 10 ,
293+ on_changed_callback = self .set_console_columns ,
294+ ),
295+ IntItem (
296+ "Console height: {}" , g .config .console .rows , min_value = 10 , on_changed_callback = self .set_console_rows
297+ ),
177298 ]
178299 if hasattr (g , "world" ):
179- items .insert (0 , MenuItem ("Continue" , self .continue_ ))
300+ items .insert (0 , SelectItem ("Continue" , self .continue_ ))
180301
181302 super ().__init__ (
182303 items = tuple (items ),
@@ -185,16 +306,29 @@ def __init__(self) -> None:
185306 y = 5 ,
186307 )
187308
188- def continue_ (self ) -> StateResult :
309+ @staticmethod
310+ def set_console_columns (v : int ) -> None :
311+ """Adjust the console size."""
312+ g .config .console .columns = v
313+
314+ @staticmethod
315+ def set_console_rows (v : int ) -> None :
316+ """Adjust the console size."""
317+ g .config .console .rows = v
318+
319+ @staticmethod
320+ def continue_ () -> StateResult :
189321 """Return to the game."""
190322 return Reset (InGame ())
191323
192- def new_game (self ) -> StateResult :
324+ @staticmethod
325+ def new_game () -> StateResult :
193326 """Begin a new game."""
194327 g .world = game .world_tools .new_world ()
195328 return Reset (InGame ())
196329
197- def quit (self ) -> StateResult :
330+ @staticmethod
331+ def quit () -> StateResult :
198332 """Close the program."""
199333 raise SystemExit ()
200334
@@ -259,12 +393,7 @@ def on_event(self, event: tcod.event.Event) -> StateResult: # noqa: PLR0911
259393
260394 def on_draw (self , console : tcod .console .Console ) -> None :
261395 """Render the menu."""
262- current_index = g .states .index (self )
263- if current_index > 0 :
264- g .states [current_index - 1 ].on_draw (console )
265- if g .states [- 1 ] is self :
266- console .rgb ["fg" ] //= 4
267- console .rgb ["bg" ] //= 4
396+ draw_previous_state (console , self )
268397
269398 console .draw_frame (
270399 self .x , self .y , self .width , self .height , fg = (255 , 255 , 255 ), bg = (0 , 0 , 0 ), decoration = "┌─┐│ │└─┘"
0 commit comments