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,
44 self.urwid_pipe_write_fd = self.main_loop.watch_pipe(input_handler)
45 self.recv_loop_thread = threading.Thread(target=self.recv_loop)
47 def setup_widgets(self):
48 """Return container widget with all widgets we want on our screen.
50 Sets up an urwid.Pile inside a returned urwid.Filler; top to bottom:
51 - an EditToSocketWidget, prefixing self.socket input with 'SEND: '
52 - a 50-col wide urwid.Padding container for self.map_widget, which is
53 to print clipped map representations
54 - self.reply_widget, a urwid.Text widget printing self.socket replies
56 edit_widget = self.EditToSocketWidget(self.socket, 'SEND: ')
57 self.reply_widget = self.LogWidget('')
58 self.map_widget = self.MapWidget('', wrap='clip')
59 map_box = urwid.Padding(self.map_widget, width=50)
60 widget_pile = urwid.Pile([edit_widget, map_box, self.reply_widget])
61 return urwid.Filler(widget_pile, valign='top')
63 class EditToSocketWidget(urwid.Edit):
64 """Extends urwid.Edit with socket to send input on 'enter' to."""
66 def __init__(self, socket, *args, **kwargs):
67 super().__init__(*args, **kwargs)
70 def keypress(self, size, key):
71 """Extend super(): on Enter, send .edit_text, and empty it."""
73 return super().keypress(size, key)
74 plom_socket_io.send(self.socket, self.edit_text)
77 class LogWidget(urwid.Text):
78 """Display client log, newest message on top."""
81 """Add text to (top of) log."""
82 self.set_text(text + '\n' + self.text)
84 class MapWidget(urwid.Text):
85 """Stores/updates/draws game map."""
87 terrain_map = ' ' * 25
91 """Draw map view from .map_size, .terrain_map, .position."""
93 for c in self.terrain_map:
95 pos_i = self.position[0] * (self.map_size[1] + 1) + self.position[1]
96 whole_map[pos_i] = '@'
97 self.set_text(''.join(whole_map))
99 def get_yx(self, yx_string):
101 def get_axis_position_from_argument(axis, token):
102 if len(token) < 3 or token[:2] != axis + ':' or \
103 not token[2:].isdigit():
104 raise ArgumentError('Bad arg for ' + axis + ' position.')
105 return int(token[2:])
107 tokens = yx_string.split(',')
109 raise ArgumentError('wrong number of ","-separated arguments')
110 y = get_axis_position_from_argument('Y', tokens[0])
111 x = get_axis_position_from_argument('X', tokens[1])
114 def update_map_size(self, size_string):
115 """Set map size, redo self.terrain_map in new size, '?'-filled."""
116 new_map_size = self.get_yx(size_string)
117 if 0 in new_map_size:
118 raise ArgumentError('size value for either axis must be >0')
119 self.map_size = new_map_size
120 self.terrain_map = ''
121 for y in range(self.map_size[0]):
122 self.terrain_map += '?' * self.map_size[1] + '\n'
125 def update_terrain(self, terrain_map):
126 """Update self.terrain_map. Ensure size matching self.map_size."""
127 lines = terrain_map.split('\n')
128 if len(lines) != self.map_size[0]:
129 raise ArgumentError('wrong map height')
131 if len(line) != self.map_size[1]:
132 raise ArgumentError('wrong map width')
133 self.terrain_map = terrain_map
136 def update_position(self, position_string):
137 """Update self.position, ensure it's within map bounds."""
139 def get_axis_position_from_argument(axis, token):
140 if len(token) < 3 or token[:2] != axis + ':' or \
141 not token[2:].isdigit():
142 raise ArgumentError('Bad arg for ' + axis + ' position.')
143 return int(token[2:])
145 new_position = self.get_yx(position_string)
146 if new_position[0] >= self.map_size[0] or \
147 new_position[1] >= self.map_size[1]:
148 raise ArgumentError('Position outside of map size bounds.')
149 self.position = new_position
153 """Delivers data from other thread to widget via message_container.
155 The class only exists to provide handle_input as a bound method, with
156 widget and message_container pre-set, as (bound) handle_input is used
157 as a callback in urwid's watch_pipe – which merely provides its
158 callback target with one parameter for a pipe to read data from an
159 urwid-external thread.
162 def __init__(self, log_widget, map_widget, message_container):
163 self.log_widget = log_widget
164 self.map_widget = map_widget
165 self.message_container = message_container
167 def handle_input(self, trigger):
168 """On input from other thread, either quit or write to widget text.
170 Serves as a receiver to urwid's watch_pipe mechanism, with trigger
171 the data that a pipe defined by watch_pipe delivers. To avoid
172 buffering trouble, we don't care for that data beyond the fact that
173 its receival triggers this function: The sender is to write the
174 data it wants to deliver into the container referenced by
175 self.message_container, and just pipe the trigger to inform us
178 If the message delivered is 'BYE', quits Urwid.
181 def mapdraw_command(prefix, func):
183 if len(msg) > n and msg[:n] == prefix:
184 m = getattr(self.map_widget, func)
189 msg = self.message_container[0]
191 raise urwid.ExitMainLoop()
193 found_command = False
196 mapdraw_command('TERRAIN\n', 'update_terrain') or
197 mapdraw_command('POSITION ', 'update_position') or
198 mapdraw_command('MAP_SIZE ', 'update_map_size'))
199 except ArgumentError as e:
200 self.log_widget.add('ARGUMENT ERROR: ' + msg + '\n' + str(e))
202 if not found_command:
203 self.log_widget.add('UNHANDLED INPUT: ' + msg)
204 del self.message_container[0]
207 """Loop to receive messages from socket and deliver them to urwid.
209 Waits for self.server_output to become empty (this signals that the
210 input handler is finished / ready to receive new input), then writes
211 finished message from socket to self.server_output, then sends a single
212 b' ' through self.urwid_pipe_write_fd to trigger the input handler.
215 for msg in plom_socket_io.recv(self.socket):
216 while len(self.server_output) > 0:
218 self.server_output += [msg]
219 os.write(self.urwid_pipe_write_fd, b' ')
222 """Run in parallel main and recv_loop thread."""
223 self.recv_loop_thread.start()
225 self.recv_loop_thread.join()
228 s = socket.create_connection(('127.0.0.1', 5000))