home · contact · privacy
2e30fe08452dc90fa51723e212a57fb80e315c43
[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 UrwidSetup:
10
11     def __init__(self, socket):
12         """Build client urwid interface around socket communication.
13
14         Sets up all widgets for writing to the socket and representing data
15         from it. Sending via a self.EditToSocket widget is straightforward;
16         polling the socket for input from the server in parallel to the urwid
17         main loop not so much:
18
19         The urwid developers warn against sharing urwid resources among
20         threads, so having a socket polling thread for writing to an urwid
21         widget while other widgets are handled in other threads would be
22         dangerous. Urwid developers recommend using urwid's watch_pipe
23         mechanism instead: using a pipe from non-urwid threads into a single
24         urwid thread. We use self.recv_loop_thread to poll the socket, therein
25         write socket.recv output to an object that is then linked to by
26         self.server_output (which is known to the urwid thread), then use the
27         pipe to urwid to trigger it pulling new data from self.server_output to
28         handle via self.InputHandler. (We *could* pipe socket.recv output
29         directly, but then we get complicated buffering situations here as well
30         as in the urwid code that receives the pipe output. It's easier to just
31         tell the urwid code where it finds full new server messages to handle.)
32         """
33         self.socket = socket
34         self.main_loop = urwid.MainLoop(self.setup_widgets())
35         self.server_output = []
36         input_handler = getattr(self.InputHandler(self.reply_widget,
37                                                   self.map_widget,
38                                                   self.server_output),
39                                 'handle_input')
40         self.urwid_pipe_write_fd = self.main_loop.watch_pipe(input_handler)
41         self.recv_loop_thread = threading.Thread(target=self.recv_loop)
42
43     def setup_widgets(self):
44         """Return container widget with all widgets we want on our screen.
45
46         Sets up an urwid.Pile inside a returned urwid.Filler; top to bottom:
47         - an EditToSocketWidget, prefixing self.socket input with 'SEND: '
48         - a 50-col wide urwid.Padding container for self.map_widget, which is
49           to print clipped map representations
50         - self.reply_widget, a urwid.Text widget printing self.socket replies
51         """
52         edit_widget = self.EditToSocketWidget(self.socket, 'SEND: ')
53         self.reply_widget = urwid.Text('')
54         self.map_widget = self.MapWidget('', wrap='clip')
55         map_box = urwid.Padding(self.map_widget, width=50)
56         widget_pile = urwid.Pile([edit_widget, map_box, self.reply_widget])
57         return urwid.Filler(widget_pile, valign='top')
58
59     class EditToSocketWidget(urwid.Edit):
60         """Extends urwid.Edit with socket to send input on 'enter' to."""
61
62         def __init__(self, socket, *args, **kwargs):
63             super().__init__(*args, **kwargs)
64             self.socket = socket
65
66         def keypress(self, size, key):
67             """Extend super(): on Enter, send .edit_text, and empty it."""
68             if key != 'enter':
69                 return super().keypress(size, key)
70             plom_socket_io.send(self.socket, self.edit_text)
71             self.edit_text = ''
72
73     class MapWidget(urwid.Text):
74         """Stores/updates/draws game map."""
75         terrain_map = ' ' * 25
76         position = [0, 0]
77
78         def draw_map(self):
79             """Draw map view from .terrain_map, .position."""
80             whole_map = []
81             for c in self.terrain_map:
82                 whole_map += [c]
83             pos_i = self.position[0] * (5 + 1) + self.position[1]
84             whole_map[pos_i] = '@'
85             self.set_text(''.join(whole_map))
86
87         def update_terrain(self, terrain_map):
88             """Update self.terrain_map."""
89             self.terrain_map = terrain_map
90             self.draw_map()
91
92         def update_position_y(self, position_y_string):
93             """Update self.position[0]."""
94             self.position[0] = int(position_y_string)
95             self.draw_map()
96
97         def update_position_x(self, position_x_string):
98             """Update self.position[1]."""
99             self.position[1] = int(position_x_string)
100             self.draw_map()
101
102     class InputHandler:
103         """Delivers data from other thread to widget via message_container.
104
105         The class only exists to provide handle_input as a bound method, with
106         widget and message_container pre-set, as (bound) handle_input is used
107         as a callback in urwid's watch_pipe – which merely provides its
108         callback target with one parameter for a pipe to read data from an
109         urwid-external thread.
110         """
111
112         def __init__(self, widget1, widget2, message_container):
113             self.widget1 = widget1
114             self.widget2 = widget2
115             self.message_container = message_container
116
117         def handle_input(self, trigger):
118             """On input from other thread, either quit or write to widget text.
119
120             Serves as a receiver to urwid's watch_pipe mechanism, with trigger
121             the data that a pipe defined by watch_pipe delivers. To avoid
122             buffering trouble, we don't care for that data beyond the fact that
123             its receival triggers this function: The sender is to write the
124             data it wants to deliver into the container referenced by
125             self.message_container, and just pipe the trigger to inform us
126             about this.
127
128             If the message delivered is 'BYE', quits Urwid.
129             """
130
131             def mapdraw_command(prefix, func):
132                 n = len(prefix)
133                 if len(msg) > n and msg[:n] == prefix:
134                     m = getattr(self.widget2, func)
135                     m(msg[n:])
136                     return True
137                 return False
138
139             msg = self.message_container[0]
140             if msg == 'BYE':
141                 raise urwid.ExitMainLoop()
142                 return
143             found_command = False
144             try:
145                 found_command = (
146                     mapdraw_command('TERRAIN\n', 'update_terrain') or
147                     mapdraw_command('POSITION_Y ', 'update_position_y') or
148                     mapdraw_command('POSITION_X ', 'update_position_x'))
149             except Exception as e:
150                 self.widget1.set_text('TROUBLESOME ARGUMENT: ' + msg + '\n' +
151                                       str(e))
152             else:
153                 if not found_command:
154                     self.widget1.set_text('UNKNOWN COMMAND: ' + msg)
155             del self.message_container[0]
156
157     def recv_loop(self):
158         """Loop to receive messages from socket and deliver them to urwid.
159
160         Waits for self.server_output to become empty (this signals that the
161         input handler is finished / ready to receive new input), then writes
162         finished message from socket to self.server_output, then sends a single
163         b' ' through self.urwid_pipe_write_fd to trigger the input handler.
164         """
165         import os
166         for msg in plom_socket_io.recv(self.socket):
167             while len(self.server_output) > 0:
168                 pass
169             self.server_output += [msg]
170             os.write(self.urwid_pipe_write_fd, b' ')
171
172     def run(self):
173         """Run in parallel main and recv_loop thread."""
174         self.recv_loop_thread.start()
175         self.main_loop.run()
176         self.recv_loop_thread.join()
177
178
179 s = socket.create_connection(('127.0.0.1', 5000))
180 u = UrwidSetup(s)
181 u.run()
182 s.close()