home · contact · privacy
Add turn widget to client.
[plomrogue2-experiments] / client.py
1 #!/usr/bin/env python3
2
3 import urwid
4 import plom_socket_io
5 import socket
6 import threading
7
8
9 class ArgumentError(Exception):
10     pass
11
12
13 class UrwidSetup:
14
15     def __init__(self, socket):
16         """Build client urwid interface around socket communication.
17
18         Sets up all widgets for writing to the socket and representing data
19         from it. Sending via a self.EditToSocket widget is straightforward;
20         polling the socket for input from the server in parallel to the urwid
21         main loop not so much:
22
23         The urwid developers warn against sharing urwid resources among
24         threads, so having a socket polling thread for writing to an urwid
25         widget while other widgets are handled in other threads would be
26         dangerous. Urwid developers recommend using urwid's watch_pipe
27         mechanism instead: using a pipe from non-urwid threads into a single
28         urwid thread. We use self.recv_loop_thread to poll the socket, therein
29         write socket.recv output to an object that is then linked to by
30         self.server_output (which is known to the urwid thread), then use the
31         pipe to urwid to trigger it pulling new data from self.server_output to
32         handle via self.InputHandler. (We *could* pipe socket.recv output
33         directly, but then we get complicated buffering situations here as well
34         as in the urwid code that receives the pipe output. It's easier to just
35         tell the urwid code where it finds full new server messages to handle.)
36         """
37         self.socket = socket
38         self.main_loop = urwid.MainLoop(self.setup_widgets())
39         self.server_output = []
40         input_handler = getattr(self.InputHandler(self.reply_widget,
41                                                   self.map_widget,
42                                                   self.turn_widget,
43                                                   self.server_output),
44                                 'handle_input')
45         self.urwid_pipe_write_fd = self.main_loop.watch_pipe(input_handler)
46         self.recv_loop_thread = threading.Thread(target=self.recv_loop)
47
48     def setup_widgets(self):
49         """Return container widget with all widgets we want on our screen.
50
51         Sets up an urwid.Pile inside a returned urwid.Filler; top to bottom:
52         - an EditToSocketWidget, prefixing self.socket input with 'SEND: '
53         - a 50-col wide urwid.Padding container for self.map_widget, which is
54           to print clipped map representations
55         - self.reply_widget, a urwid.Text widget printing self.socket replies
56         """
57         edit_widget = self.EditToSocketWidget(self.socket, 'SEND: ')
58         self.map_widget = self.MapWidget('', wrap='clip')
59         self.turn_widget = self.TurnWidget('')
60         self.reply_widget = self.LogWidget('')
61         map_box = urwid.Padding(self.map_widget, width=50)
62         widget_pile = urwid.Pile([edit_widget, map_box, self.turn_widget,
63                                   self.reply_widget])
64         return urwid.Filler(widget_pile, valign='top')
65
66     class EditToSocketWidget(urwid.Edit):
67         """Extends urwid.Edit with socket to send input on 'enter' to."""
68
69         def __init__(self, socket, *args, **kwargs):
70             super().__init__(*args, **kwargs)
71             self.socket = socket
72
73         def keypress(self, size, key):
74             """Extend super(): on Enter, send .edit_text, and empty it."""
75             if key != 'enter':
76                 return super().keypress(size, key)
77             plom_socket_io.send(self.socket, self.edit_text)
78             self.edit_text = ''
79
80     class TurnWidget(urwid.Text):
81         """Displays turn number."""
82
83         def set_turn(self, turn_string):
84             turn_string = turn_string.strip()
85             if not turn_string.isdigit():
86                 raise ArgumentError('Argument must be non-negative integer.')
87             self.set_text('TURN: ' + turn_string)
88
89     class LogWidget(urwid.Text):
90         """Displays client log, newest message on top."""
91
92         def add(self, text):
93             """Add text plus newline to (top of) log."""
94             self.set_text(text + '\n' + self.text)
95
96     class MapWidget(urwid.Text):
97         """Stores/updates/draws game map."""
98         map_size = (5, 5)
99         terrain_map = ' ' * 25
100         position = (0, 0)
101         things = []
102
103         class Thing:
104             def __init__(self, position, symbol):
105                 self.position = position
106                 self.symbol = symbol
107
108         def draw_map(self):
109             """Draw map view from .map_size, .terrain_map, .position."""
110             whole_map = []
111             for c in self.terrain_map:
112                 whole_map += [c]
113             for t in self.things:
114                 pos_i = t.position[0] * (self.map_size[1] + 1) + t.position[1]
115                 whole_map[pos_i] = t.symbol
116             self.set_text(''.join(whole_map))
117
118         def get_yx(self, yx_string):
119
120             def get_axis_position_from_argument(axis, token):
121                 if len(token) < 3 or token[:2] != axis + ':' or \
122                         not token[2:].isdigit():
123                     raise ArgumentError('Bad arg for ' + axis + ' position.')
124                 return int(token[2:])
125
126             tokens = yx_string.split(',')
127             if len(tokens) != 2:
128                 raise ArgumentError('wrong number of ","-separated arguments')
129             y = get_axis_position_from_argument('Y', tokens[0])
130             x = get_axis_position_from_argument('X', tokens[1])
131             return (y, x)
132
133         def update_map_size(self, size_string):
134             """Set map size, redo self.terrain_map in new size, '?'-filled."""
135             new_map_size = self.get_yx(size_string)
136             if 0 in new_map_size:
137                 raise ArgumentError('size value for either axis must be >0')
138             self.map_size = new_map_size
139             self.terrain_map = ''
140             for y in range(self.map_size[0]):
141                 self.terrain_map += '?' * self.map_size[1] + '\n'
142             self.draw_map()
143
144         def update_terrain(self, terrain_map):
145             """Update self.terrain_map. Ensure size matching self.map_size."""
146             lines = terrain_map.split('\n')
147             if len(lines) != self.map_size[0]:
148                 raise ArgumentError('wrong map height')
149             for line in lines:
150                 if len(line) != self.map_size[1]:
151                     raise ArgumentError('wrong map width')
152             self.terrain_map = terrain_map
153             self.draw_map()
154
155         def update_things(self, thing_description):
156             """Append thing of thing_description to self.things."""
157             thing_types = {'human': '@', 'monster': 'M'}
158             tokens = thing_description.split()
159             if len(tokens) != 2:
160                 raise ArgumentError('Wrong number of tokens.')
161             yx = self.get_yx(tokens[1])
162             if yx[0] >= self.map_size[0] or yx[1] >= self.map_size[1]:
163                 raise ArgumentError('Position outside of map size bounds.')
164             type_token = tokens[0]
165             prefix = 'TYPE:'
166             type_ = '?'
167             if len(type_token) <= len(prefix) or \
168                     type_token[:len(prefix)] != prefix:
169                 raise ArgumentError('Invalid type token.')
170             type_ = type_token[len(prefix):]
171             if type_ not in thing_types:
172                 raise ArgumentError('Unknown thing type.')
173             self.things += [self.Thing(yx, thing_types[type_])]
174             self.draw_map()
175
176         def clear_things(self, _):
177             self.things = []
178
179     class InputHandler:
180         """Delivers data from other thread to widget via message_container.
181
182         The class only exists to provide handle_input as a bound method, with
183         widget and message_container pre-set, as (bound) handle_input is used
184         as a callback in urwid's watch_pipe – which merely provides its
185         callback target with one parameter for a pipe to read data from an
186         urwid-external thread.
187         """
188
189         def __init__(self, log_widget, map_widget, turn_widget,
190                      message_container):
191             self.log_widget = log_widget
192             self.map_widget = map_widget
193             self.turn_widget = turn_widget
194             self.message_container = message_container
195
196         def handle_input(self, trigger):
197             """On input from other thread, either quit or write to widget text.
198
199             Serves as a receiver to urwid's watch_pipe mechanism, with trigger
200             the data that a pipe defined by watch_pipe delivers. To avoid
201             buffering trouble, we don't care for that data beyond the fact that
202             its receival triggers this function: The sender is to write the
203             data it wants to deliver into the container referenced by
204             self.message_container, and just pipe the trigger to inform us
205             about this.
206
207             If the message delivered is 'BYE', quits Urwid.
208             """
209
210             def mapdraw_command(prefix, func):
211                 n = len(prefix)
212                 if len(msg) > n and msg[:n] == prefix:
213                     m = getattr(self.map_widget, func)
214                     m(msg[n:])
215                     return True
216                 return False
217
218             def turndraw_command(prefix, func):
219                 n = len(prefix)
220                 if len(msg) > n and msg[:n] == prefix:
221                     m = getattr(self.turn_widget, func)
222                     m(msg[n:])
223                     return True
224                 return False
225
226             msg = self.message_container[0]
227             if msg == 'BYE':
228                 raise urwid.ExitMainLoop()
229                 return
230             found_command = False
231             try:
232                 found_command = turndraw_command('NEW_TURN ', 'set_turn') or (
233                     mapdraw_command('NEW_TURN ', 'clear_things') or
234                     mapdraw_command('TERRAIN\n', 'update_terrain') or
235                     mapdraw_command('THING ', 'update_things') or
236                     mapdraw_command('MAP_SIZE ', 'update_map_size'))
237             except ArgumentError as e:
238                 self.log_widget.add('ARGUMENT ERROR: ' + msg + '\n' + str(e))
239             else:
240                 if not found_command:
241                     self.log_widget.add('UNHANDLED INPUT: ' + msg)
242             del self.message_container[0]
243
244     def recv_loop(self):
245         """Loop to receive messages from socket and deliver them to urwid.
246
247         Waits for self.server_output to become empty (this signals that the
248         input handler is finished / ready to receive new input), then writes
249         finished message from socket to self.server_output, then sends a single
250         b' ' through self.urwid_pipe_write_fd to trigger the input handler.
251         """
252         import os
253         for msg in plom_socket_io.recv(self.socket):
254             while len(self.server_output) > 0:
255                 pass
256             self.server_output += [msg]
257             os.write(self.urwid_pipe_write_fd, b' ')
258
259     def run(self):
260         """Run in parallel main and recv_loop thread."""
261         self.recv_loop_thread.start()
262         self.main_loop.run()
263         self.recv_loop_thread.join()
264
265
266 s = socket.create_connection(('127.0.0.1', 5000))
267 u = UrwidSetup(s)
268 u.run()
269 s.close()