9 class ArgumentError(Exception):
15 def __init__(self, socket):
16 """Build client urwid interface around socket communication.
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:
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.)
38 self.main_loop = urwid.MainLoop(self.setup_widgets())
39 self.server_output = []
40 input_handler = getattr(self.InputHandler(self.reply_widget,
45 self.urwid_pipe_write_fd = self.main_loop.watch_pipe(input_handler)
46 self.recv_loop_thread = threading.Thread(target=self.recv_loop)
48 def setup_widgets(self):
49 """Return container widget with all widgets we want on our screen.
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
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,
64 return urwid.Filler(widget_pile, valign='top')
66 class EditToSocketWidget(urwid.Edit):
67 """Extends urwid.Edit with socket to send input on 'enter' to."""
69 def __init__(self, socket, *args, **kwargs):
70 super().__init__(*args, **kwargs)
73 def keypress(self, size, key):
74 """Extend super(): on Enter, send .edit_text, and empty it."""
76 return super().keypress(size, key)
77 plom_socket_io.send(self.socket, self.edit_text)
80 class TurnWidget(urwid.Text):
81 """Displays turn number."""
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)
89 class LogWidget(urwid.Text):
90 """Displays client log, newest message on top."""
93 """Add text plus newline to (top of) log."""
94 self.set_text(text + '\n' + self.text)
96 class MapWidget(urwid.Text):
97 """Stores/updates/draws game map."""
99 terrain_map = ' ' * 25
104 def __init__(self, position, symbol):
105 self.position = position
109 """Draw map view from .map_size, .terrain_map, .position."""
111 for c in self.terrain_map:
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))
118 def get_yx(self, yx_string):
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:])
126 tokens = yx_string.split(',')
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])
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'
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')
150 if len(line) != self.map_size[1]:
151 raise ArgumentError('wrong map width')
152 self.terrain_map = terrain_map
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()
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]
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_])]
176 def clear_things(self, _):
180 """Delivers data from other thread to widget via message_container.
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.
189 def __init__(self, log_widget, map_widget, turn_widget,
191 self.log_widget = log_widget
192 self.map_widget = map_widget
193 self.turn_widget = turn_widget
194 self.message_container = message_container
196 def handle_input(self, trigger):
197 """On input from other thread, either quit or write to widget text.
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
207 If the message delivered is 'BYE', quits Urwid.
210 def mapdraw_command(prefix, func):
212 if len(msg) > n and msg[:n] == prefix:
213 m = getattr(self.map_widget, func)
218 def turndraw_command(prefix, func):
220 if len(msg) > n and msg[:n] == prefix:
221 m = getattr(self.turn_widget, func)
226 msg = self.message_container[0]
228 raise urwid.ExitMainLoop()
230 found_command = False
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))
240 if not found_command:
241 self.log_widget.add('UNHANDLED INPUT: ' + msg)
242 del self.message_container[0]
245 """Loop to receive messages from socket and deliver them to urwid.
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.
253 for msg in plom_socket_io.recv(self.socket):
254 while len(self.server_output) > 0:
256 self.server_output += [msg]
257 os.write(self.urwid_pipe_write_fd, b' ')
260 """Run in parallel main and recv_loop thread."""
261 self.recv_loop_thread.start()
263 self.recv_loop_thread.join()
266 s = socket.create_connection(('127.0.0.1', 5000))