home · contact · privacy
Add map data transfer, fix socket reading queue.
[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         - self.reply_widget, a urwid.Text widget printing self.socket replies
49         - a 50-col wide urwid.Padding container for self.map_widget, which is
50           to print clipped map representations
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, self.reply_widget, map_box])
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 Urbit.
129             """
130             msg = self.message_container[0]
131             if msg == 'BYE':
132                 raise urwid.ExitMainLoop()
133                 return
134             if len(msg) > 8 and msg[:8] == 'TERRAIN ':
135                 self.widget2.update_terrain(msg[8:])
136             elif len(msg) > 11 and msg[:11] == 'POSITION_Y ':
137                 self.widget2.update_position_y(msg[11:])
138             elif len(msg) > 11 and msg[:11] == 'POSITION_X ':
139                 self.widget2.update_position_x(msg[11:])
140             else:
141                 self.widget1.set_text('SERVER: ' + msg)
142             del self.message_container[0]
143
144     def recv_loop(self):
145         """Loop to receive messages from socket and deliver them to urwid.
146
147         Waits for self.server_output to become empty (this signals that the
148         input handler is finished / ready to receive new input), then writes
149         finished message from socket to self.server_output, then sends a single
150         b' ' through self.urwid_pipe_write_fd to trigger the input handler.
151         """
152         import os
153         for msg in plom_socket_io.recv(self.socket):
154             while len(self.server_output) > 0:
155                 pass
156             self.server_output += [msg]
157             os.write(self.urwid_pipe_write_fd, b' ')
158
159     def run(self):
160         """Run in parallel main and recv_loop thread."""
161         self.recv_loop_thread.start()
162         self.main_loop.run()
163         self.recv_loop_thread.join()
164
165
166 s = socket.create_connection(('127.0.0.1', 5000))
167 u = UrwidSetup(s)
168 u.run()
169 s.close()