Accept 'j' and 'k' for 'down' and 'up' in CEO menus.
[public/pyceo-broken.git] / pylib / csc / apps / legacy / helpers.py
1 """
2 Helpers for legacy User Interface
3
4 This module contains numerous functions that are designed to immitate
5 the look and behavior of the previous CEO. Included is code for various
6 curses-based UI widgets that were provided by Perl 5's Curses and
7 Curses::Widgets libraries.
8
9 Though attempts have been made to keep the UI [bug-]compatible with
10 the previous system, some compromises have been made. For example,
11 the input and textboxes draw 'OK' and 'Cancel' buttons where the old
12 CEO had them, but they are fake. That is, the buttons in the old
13 CEO were selectable but non-operational, but in the new CEO they are
14 not even selectable.
15 """
16 import curses.ascii
17
18 # key constants not defined in CURSES
19 KEY_RETURN = ord('\n')
20 KEY_ESCAPE = 27
21 KEY_EOT = 4
22
23
24 def center(parent_dim, child_dim):
25     """Helper for centering a length in a larget length."""
26     return (parent_dim-child_dim)/2
27
28
29 def read_input(wnd, offy, offx, width, maxlen, echo=True):
30     """
31     Read user input within a confined region of a window.
32
33     Basic line-editing is supported:
34         LEFT, RIGHT, HOME, and END move around.
35         BACKSPACE and DEL remove characters.
36         INSERT switches between insert and overwrite mode.
37         ESC and C-d abort input.
38         RETURN completes input.
39     
40     Parameters:
41         wnd    - parent window for region
42         offy   - the vertical offset for the beginning of the input region
43         offx   - the horizontal offset for the beginning of the input region
44         width  - the width of the region
45         maxlen - greatest number of characters to read (0 for no limit)
46         echo   - boolean: whether to display typed characters
47     
48     Returns: the string, or None when the user aborts.
49     """
50
51     # turn on cursor
52     try:
53         curses.curs_set(1)
54     except curses.error:
55         pass
56
57     # set keypad mode to allow UP, DOWN, etc
58     wnd.keypad(1)
59
60     # the input string
61     inputbuf = ""
62
63     # offset of cursor in input
64     # i.e. the next operation is applied at input[inputoff]
65     inputoff = 0
66
67     # display offset (for scrolling)
68     # i.e. the first character in the region is input[displayoff]
69     displayoff = 0
70
71     # insert mode (True) or overwrite mode (False)
72     insert = True
73
74     while True:
75
76         # echo mode, display the string
77         if echo:
78             # discard characters before displayoff, 
79             # as the window may be scrolled to the right
80             substring = inputbuf[displayoff:]
81     
82             # pad the string with zeroes to overwrite stale characters
83             substring = substring + " " * (width - len(substring))
84     
85             # display the substring
86             wnd.addnstr(offy, offx, substring, width)
87     
88             # await input
89             key = wnd.getch(offy, offx + inputoff - displayoff)
90
91         # not echo mode, don't display the string
92         else:
93             # await input at arbitrary location
94             key = wnd.getch(offy, offx)
95
96         # enter returns input
97         if key == KEY_RETURN:
98             return inputbuf
99
100         # escape aborts input
101         elif key == KEY_ESCAPE:
102             return None
103
104         # EOT (C-d) aborts if there is no input
105         elif key == KEY_EOT:
106             if len(inputbuf) == 0:
107                 return None
108
109         # backspace removes the previous character
110         elif key == curses.KEY_BACKSPACE:
111             if inputoff > 0:
112
113                 # remove the character immediately before the input offset
114                 inputbuf = inputbuf[0:inputoff-1] + inputbuf[inputoff:]
115                 inputoff -= 1
116
117                 # move either the cursor or entire line of text left
118                 if displayoff > 0:
119                     displayoff -= 1
120
121         # delete removes the current character
122         elif key == curses.KEY_DC:
123             if inputoff < len(input):
124                 
125                 # remove the character at the input offset
126                 inputbuf = inputbuf[0:inputoff] + inputbuf[inputoff+1:]
127
128         # left moves the cursor one character left
129         elif key == curses.KEY_LEFT:
130             if inputoff > 0:
131
132                 # move the cursor to the left
133                 inputoff -= 1
134
135                 # scroll left if necessary
136                 if inputoff < displayoff:
137                     displayoff -= 1
138
139         # right moves the cursor one character right
140         elif key == curses.KEY_RIGHT:
141             if inputoff < len(inputbuf):
142                 
143                 # move the cursor to the right
144                 inputoff += 1
145
146                 # scroll right if necessary
147                 if displayoff - inputoff == width:
148                     displayoff += 1
149
150         # home moves the cursor to the first character
151         elif key == curses.KEY_HOME:
152             inputoff = 0
153             displayoff = 0
154
155         # end moves the cursor past the last character
156         elif key == curses.KEY_END:
157             inputoff = len(inputbuf)
158             displayoff = len(inputbuf) - width + 1
159
160         # insert toggles insert/overwrite mode
161         elif key == curses.KEY_IC:
162             insert = not insert
163
164         # other (printable) characters are added to the input string
165         elif curses.ascii.isprint(key):
166             if len(inputbuf) < maxlen or maxlen == 0:
167
168                 # insert mode: insert before current offset
169                 if insert:
170                     inputbuf = inputbuf[0:inputoff] + chr(key) + inputbuf[inputoff:]
171     
172                 # overwrite mode: replace current offset
173                 else:
174                     inputbuf = inputbuf[0:inputoff] + chr(key) + inputbuf[inputoff+1:]
175     
176                 # increment the input offset
177                 inputoff += 1
178     
179                 # scroll right if necessary
180                 if inputoff - displayoff == width:
181                     displayoff += 1
182
183
184 def inputbox(wnd, prompt, field_width, echo=True):
185     """Display a window for user input."""
186
187     wnd_height, wnd_width = wnd.getmaxyx()
188     height, width = 12, field_width + 7
189
190     # draw a window for the dialog
191     childy, childx = center(wnd_height-1, height)+1, center(wnd_width, width)
192     child_wnd = wnd.subwin(height, width, childy, childx)
193     child_wnd.clear()
194     child_wnd.border()
195
196     # draw another window for the text box
197     texty, textx = center(height-1, 3)+1, center(width-1, width-5)+1
198     textheight, textwidth = 3, width-5
199     text_wnd = child_wnd.derwin(textheight, textwidth, texty, textx)
200     text_wnd.clear()
201     text_wnd.border()
202     
203     # draw the prompt
204     prompty, promptx = 2, 3
205     child_wnd.addnstr(prompty, promptx, prompt, width-2)
206
207     # draw the fake buttons
208     fakey, fakex = 9, width - 19
209     child_wnd.addstr(fakey, fakex, "< OK > < Cancel >")
210     child_wnd.addch(fakey, fakex+2, "O", curses.A_UNDERLINE)
211     child_wnd.addch(fakey, fakex+9, "C", curses.A_UNDERLINE)
212
213     # update the screen
214     child_wnd.noutrefresh()
215     text_wnd.noutrefresh()
216     curses.doupdate()
217
218     # read an input string within the field region of text_wnd
219     inputy, inputx, inputwidth = 1, 1, textwidth - 2
220     inputbuf = read_input(text_wnd, inputy, inputx, inputwidth, 0, echo)
221     
222     # erase the window
223     child_wnd.erase()
224     child_wnd.refresh()
225
226     return inputbuf
227
228
229 def line_wrap(line, width):
230     """Wrap a string to a certain width (returns a list of strings)."""
231
232     wrapped_lines = []
233     tokens = line.split(" ")
234     tokens.reverse()
235     tmp = tokens.pop()
236     if len(tmp) > width:
237         wrapped_lines.append(tmp[0:width])
238         tmp = tmp[width:]
239     while len(tokens) > 0:
240         token = tokens.pop()
241         if len(tmp) + len(token) + 1 <= width:
242             tmp += " " + token
243         elif len(token) > width:
244             tmp += " " + token[0:width-len(tmp)-1]
245             tokens.push(token[width-len(tmp)-1:])
246         else:
247             wrapped_lines.append(tmp)
248             tmp = token
249     wrapped_lines.append(tmp)
250     return wrapped_lines
251
252
253 def msgbox(wnd, msg, title="Message"):
254     """Display a message in a window."""
255
256     # split message into a list of lines
257     lines = msg.split("\n")
258     
259     # determine the dimensions of the method
260     message_height = len(lines)
261     message_width = 0
262     for line in lines:
263         if len(line) > message_width:
264             message_width = len(line)
265
266     # ensure the window fits the title
267     if len(title) > message_width:
268         message_width = len(title)
269
270     # maximum message width
271     parent_height, parent_width = wnd.getmaxyx()
272     max_message_width = parent_width - 8
273
274     # line-wrap if necessary
275     if message_width > max_message_width:
276         newlines = []
277         for line in lines:
278             for newline in line_wrap(line, max_message_width):
279                 newlines.append(newline)
280         lines = newlines
281         message_width = max_message_width
282         message_height = len(lines)
283
284     # random padding that perl's curses adds
285     pad_width = 2
286
287     # create the outer window
288     outer_height, outer_width = message_height + 8, message_width + pad_width + 6
289     outer_y, outer_x = center(parent_height+1, outer_height)-1, center(parent_width, outer_width)
290     outer_wnd = wnd.derwin(outer_height, outer_width, outer_y, outer_x)
291     outer_wnd.erase()
292     outer_wnd.border()
293
294     # create the inner window
295     inner_height, inner_width = message_height + 2, message_width + pad_width + 2
296     inner_y, inner_x = 2, center(outer_width, inner_width)
297     inner_wnd = outer_wnd.derwin(inner_height, inner_width, inner_y, inner_x)
298     inner_wnd.border()
299
300     # display the title
301     outer_wnd.addstr(0, 1, " " + title + " ", curses.A_REVERSE | curses.A_BOLD)
302     
303     # display the message
304     for i in xrange(len(lines)):
305         inner_wnd.addnstr(i+1, 1, lines[i], message_width)
306
307         # draw a solid block at the end of the first few lines
308         if i < len(lines) - 1:
309             inner_wnd.addch(i+1, inner_width-1, ' ', curses.A_REVERSE)
310
311     # display the fake OK button
312     fakey, fakex = outer_height - 3, outer_width - 8
313     outer_wnd.addstr(fakey, fakex, "< OK >", curses.A_REVERSE)
314     outer_wnd.addch(fakey, fakex+2, "O", curses.A_UNDERLINE | curses.A_REVERSE)
315
316     # update display
317     outer_wnd.noutrefresh()
318     inner_wnd.noutrefresh()
319     curses.doupdate()
320
321     # read a RETURN or ESC before returning
322     curses.curs_set(0)
323     outer_wnd.keypad(1)
324     while True:
325         key = outer_wnd.getch(0, 0)
326         if key == KEY_RETURN or key == KEY_ESCAPE:
327             break
328
329     # clear the window
330     outer_wnd.erase()
331     outer_wnd.refresh()
332     
333
334 def menu(wnd, offy, offx, width, options, _acquire_wnd=None):
335     """
336     Draw a menu and wait for a selection.
337
338     Parameters:
339         wnd          - parent window
340         offy         - vertical offset for menu region
341         offx         - horizontal offset for menu region
342         width        - width of menu region
343         options      - a list of selections
344         _acquire_wnd - hack to support resize: must be a function callback
345                        that returns new values for wnd, offy, offx, height,
346                        width. Unused if None.
347
348     Returns: index into options that was selected
349     """
350
351     # the currently selected option
352     selected = 0
353
354     while True:
355         # disable cursor
356         curses.curs_set(0)
357
358         # hack to support resize: recreate the
359         # parent window every iteration
360         if _acquire_wnd:
361             wnd, offy, offx, height, width = _acquire_wnd()
362
363         # keypad mode so getch() works with up, down
364         wnd.keypad(1)
365
366         # display the menu
367         for i in xrange(len(options)):
368             text, callback = options[i]
369             text = text + " " * (width - len(text))
370
371             # the selected option is displayed in reverse video
372             if i == selected:
373                 wnd.addstr(i+offy, offx, text, curses.A_REVERSE)
374             else:
375                 wnd.addstr(i+offy, offx, text)
376                     # display the member
377
378         wnd.refresh()
379         
380         # read one keypress
381         keypress = wnd.getch()
382
383         # UP moves to the previous option
384         if (keypress == curses.KEY_UP or keypress == ord('k')) and selected > 0:
385             selected = (selected - 1)
386
387         # DOWN moves to the next option
388         elif (keypress == curses.KEY_DOWN or keypress == ord('j')) and selected < len(options) - 1:
389             selected = (selected + 1)
390
391         # RETURN runs the callback for the selected option
392         elif keypress == KEY_RETURN:
393             text, callback = options[selected]
394
395             # highlight the selected option
396             text = text + " " * (width - len(text))
397             wnd.addstr(selected+offy, offx, text, curses.A_REVERSE | curses.A_BOLD)
398             wnd.refresh()
399
400             # execute the selected option
401             if callback(wnd): # success
402                 break
403
404
405 def reset():
406     """Reset the terminal and clear the screen."""
407
408     reset = curses.tigetstr('rs1')
409     if not reset: reset = '\x1bc'
410     curses.putp(reset)
411