home · contact · privacy
Add ArgumentError class, improve positioning command.
[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.server_output),
43                                 'handle_input')
44         self.urwid_pipe_write_fd = self.main_loop.watch_pipe(input_handler)
45         self.recv_loop_thread = threading.Thread(target=self.recv_loop)
46
47     def setup_widgets(self):
48         """Return container widget with all widgets we want on our screen.
49
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
55         """
56         edit_widget = self.EditToSocketWidget(self.socket, 'SEND: ')
57         self.reply_widget = urwid.Text('')
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')
62
63     class EditToSocketWidget(urwid.Edit):
64         """Extends urwid.Edit with socket to send input on 'enter' to."""
65
66         def __init__(self, socket, *args, **kwargs):
67             super().__init__(*args, **kwargs)
68             self.socket = socket
69
70         def keypress(self, size, key):
71             """Extend super(): on Enter, send .edit_text, and empty it."""
72             if key != 'enter':
73                 return super().keypress(size, key)
74             plom_socket_io.send(self.socket, self.edit_text)
75             self.edit_text = ''
76
77     class MapWidget(urwid.Text):
78         """Stores/updates/draws game map."""
79         terrain_map = ' ' * 25
80         position = (0, 0)
81
82         def draw_map(self):
83             """Draw map view from .terrain_map, .position."""
84             whole_map = []
85             for c in self.terrain_map:
86                 whole_map += [c]
87             pos_i = self.position[0] * (5 + 1) + self.position[1]
88             whole_map[pos_i] = '@'
89             self.set_text(''.join(whole_map))
90
91         def update_terrain(self, terrain_map):
92             """Update self.terrain_map."""
93             self.terrain_map = terrain_map
94             self.draw_map()
95
96         def update_position(self, position_string):
97             """Update self.position."""
98
99             def get_axis_position_from_argument(axis, token):
100                 if len(token) < 3 or token[:2] != axis + ':' or \
101                         not token[2:].isdigit():
102                     raise ArgumentError('Bad arg for ' + axis + ' position.')
103                 return int(token[2:])
104
105             tokens = position_string.split(',')
106             if len(tokens) != 2:
107                 raise ArgumentError('wrong number of ","-separated arguments')
108             y = get_axis_position_from_argument('y', tokens[0])
109             x = get_axis_position_from_argument('x', tokens[1])
110             self.position = (y, x)
111             self.draw_map()
112
113     class InputHandler:
114         """Delivers data from other thread to widget via message_container.
115
116         The class only exists to provide handle_input as a bound method, with
117         widget and message_container pre-set, as (bound) handle_input is used
118         as a callback in urwid's watch_pipe – which merely provides its
119         callback target with one parameter for a pipe to read data from an
120         urwid-external thread.
121         """
122
123         def __init__(self, widget1, widget2, message_container):
124             self.widget1 = widget1
125             self.widget2 = widget2
126             self.message_container = message_container
127
128         def handle_input(self, trigger):
129             """On input from other thread, either quit or write to widget text.
130
131             Serves as a receiver to urwid's watch_pipe mechanism, with trigger
132             the data that a pipe defined by watch_pipe delivers. To avoid
133             buffering trouble, we don't care for that data beyond the fact that
134             its receival triggers this function: The sender is to write the
135             data it wants to deliver into the container referenced by
136             self.message_container, and just pipe the trigger to inform us
137             about this.
138
139             If the message delivered is 'BYE', quits Urwid.
140             """
141
142             def mapdraw_command(prefix, func):
143                 n = len(prefix)
144                 if len(msg) > n and msg[:n] == prefix:
145                     m = getattr(self.widget2, func)
146                     m(msg[n:])
147                     return True
148                 return False
149
150             msg = self.message_container[0]
151             if msg == 'BYE':
152                 raise urwid.ExitMainLoop()
153                 return
154             found_command = False
155             try:
156                 found_command = (
157                     mapdraw_command('TERRAIN\n', 'update_terrain') or
158                     mapdraw_command('POSITION ', 'update_position'))
159             except ArgumentError as e:
160                 self.widget1.set_text('BAD ARGUMENT: ' + msg + '\n' +
161                                       str(e))
162             else:
163                 if not found_command:
164                     self.widget1.set_text('UNKNOWN COMMAND: ' + msg)
165             del self.message_container[0]
166
167     def recv_loop(self):
168         """Loop to receive messages from socket and deliver them to urwid.
169
170         Waits for self.server_output to become empty (this signals that the
171         input handler is finished / ready to receive new input), then writes
172         finished message from socket to self.server_output, then sends a single
173         b' ' through self.urwid_pipe_write_fd to trigger the input handler.
174         """
175         import os
176         for msg in plom_socket_io.recv(self.socket):
177             while len(self.server_output) > 0:
178                 pass
179             self.server_output += [msg]
180             os.write(self.urwid_pipe_write_fd, b' ')
181
182     def run(self):
183         """Run in parallel main and recv_loop thread."""
184         self.recv_loop_thread.start()
185         self.main_loop.run()
186         self.recv_loop_thread.join()
187
188
189 s = socket.create_connection(('127.0.0.1', 5000))
190 u = UrwidSetup(s)
191 u.run()
192 s.close()