blob: 5fe83ca5ba510558d07c35b5cb5c7b6919093273 [file] [log] [blame]
Hung-Te Lin6bb48552012-02-09 14:37:43 +08001#!/usr/bin/python -u
Hung-Te Linf2f78f72012-02-08 19:27:11 +08002# -*- coding: utf-8 -*-
3#
4# Copyright (c) 2010 The Chromium OS Authors. All rights reserved.
5# Use of this source code is governed by a BSD-style license that can be
6# found in the LICENSE file.
7
8
9# DESCRIPTION :
10#
11# This library provides convenience routines to launch factory tests.
12# This includes support for drawing the test widget in a window at the
13# proper location, grabbing control of the mouse, and making the mouse
14# cursor disappear.
Hung-Te Lin6bb48552012-02-09 14:37:43 +080015#
16# This UI is intended to be used by the factory autotest suite to
17# provide factory operators feedback on test status and control over
18# execution order.
19#
20# In short, the UI is composed of a 'console' panel on the bottom of
21# the screen which displays the autotest log, and there is also a
22# 'test list' panel on the right hand side of the screen. The
23# majority of the screen is dedicated to tests, which are executed in
24# seperate processes, but instructed to display their own UIs in this
25# dedicated area whenever possible. Tests in the test list are
26# executed in order by default, but can be activated on demand via
27# associated keyboard shortcuts. As tests are run, their status is
28# color-indicated to the operator -- greyed out means untested, yellow
29# means active, green passed and red failed.
Hung-Te Linf2f78f72012-02-08 19:27:11 +080030
Hung-Te Lin6bb48552012-02-09 14:37:43 +080031import logging
32import os
33import re
Tammo Spalinkdb8d7112012-03-13 18:54:37 +080034import string
Hung-Te Lin6bb48552012-02-09 14:37:43 +080035import subprocess
36import sys
Hung-Te Linf2f78f72012-02-08 19:27:11 +080037from itertools import izip, product
Jon Salz14bcbb02012-03-17 15:11:50 +080038from optparse import OptionParser
Hung-Te Linf2f78f72012-02-08 19:27:11 +080039
Hung-Te Lin6bb48552012-02-09 14:37:43 +080040# GTK and X modules
41import gobject
42import gtk
43import pango
44
Tammo Spalinkdb8d7112012-03-13 18:54:37 +080045# Guard loading Xlib because it is currently not available in the
46# image build process host-depends list. Failure to load in
47# production should always manifest during regular use.
48try:
49 from Xlib import X
50 from Xlib.display import Display
51except:
52 pass
53
Hung-Te Lin6bb48552012-02-09 14:37:43 +080054# Factory and autotest modules
Hung-Te Linf2f78f72012-02-08 19:27:11 +080055import factory_common
56from autotest_lib.client.cros import factory
57from autotest_lib.client.cros.factory import TestState
58from autotest_lib.client.cros.factory.event import Event, EventClient
59
Hung-Te Lin6bb48552012-02-09 14:37:43 +080060
Hung-Te Linf2f78f72012-02-08 19:27:11 +080061# For compatibility with tests before TestState existed
62ACTIVE = TestState.ACTIVE
63PASSED = TestState.PASSED
64FAILED = TestState.FAILED
65UNTESTED = TestState.UNTESTED
66
Hung-Te Lin6bb48552012-02-09 14:37:43 +080067# Color definition
Hung-Te Linf2f78f72012-02-08 19:27:11 +080068BLACK = gtk.gdk.Color()
69RED = gtk.gdk.Color(0xFFFF, 0, 0)
70GREEN = gtk.gdk.Color(0, 0xFFFF, 0)
71BLUE = gtk.gdk.Color(0, 0, 0xFFFF)
72WHITE = gtk.gdk.Color(0xFFFF, 0xFFFF, 0xFFFF)
Hung-Te Linf2f78f72012-02-08 19:27:11 +080073LIGHT_GREEN = gtk.gdk.color_parse('light green')
Hung-Te Linf2f78f72012-02-08 19:27:11 +080074SEP_COLOR = gtk.gdk.color_parse('grey50')
75
76RGBA_GREEN_OVERLAY = (0, 0.5, 0, 0.6)
77RGBA_YELLOW_OVERLAY = (0.6, 0.6, 0, 0.6)
78
79LABEL_COLORS = {
80 TestState.ACTIVE: gtk.gdk.color_parse('light goldenrod'),
81 TestState.PASSED: gtk.gdk.color_parse('pale green'),
82 TestState.FAILED: gtk.gdk.color_parse('tomato'),
83 TestState.UNTESTED: gtk.gdk.color_parse('dark slate grey')}
84
85LABEL_FONT = pango.FontDescription('courier new condensed 16')
Hung-Te Linbf545582012-02-15 17:08:07 +080086LABEL_LARGE_FONT = pango.FontDescription('courier new condensed 24')
Hung-Te Linf2f78f72012-02-08 19:27:11 +080087
88FAIL_TIMEOUT = 30
89
90USER_PASS_FAIL_SELECT_STR = (
91 'hit TAB to fail and ENTER to pass\n' +
92 '錯誤請按 TAB,成功請按 ENTER')
Chun-Ta Linf33efa72012-03-15 18:56:38 +080093# Resolution where original UI is designed for.
94_UI_SCREEN_WIDTH = 1280
95_UI_SCREEN_HEIGHT = 800
Hung-Te Linf2f78f72012-02-08 19:27:11 +080096
Tai-Hsu Lin606685c2012-03-14 19:10:11 +080097_LABEL_STATUS_ROW_SIZE = (300, 30)
Hung-Te Lin6bb48552012-02-09 14:37:43 +080098_LABEL_EN_SIZE = (170, 35)
99_LABEL_ZH_SIZE = (70, 35)
100_LABEL_EN_FONT = pango.FontDescription('courier new extra-condensed 16')
101_LABEL_ZH_FONT = pango.FontDescription('normal 12')
102_LABEL_T_SIZE = (40, 35)
103_LABEL_T_FONT = pango.FontDescription('arial ultra-condensed 10')
104_LABEL_UNTESTED_FG = gtk.gdk.color_parse('grey40')
105_LABEL_TROUGH_COLOR = gtk.gdk.color_parse('grey20')
106_LABEL_STATUS_SIZE = (140, 30)
107_LABEL_STATUS_FONT = pango.FontDescription(
108 'courier new bold extra-condensed 16')
109_OTHER_LABEL_FONT = pango.FontDescription('courier new condensed 20')
110
111_ST_LABEL_EN_SIZE = (250, 35)
112_ST_LABEL_ZH_SIZE = (150, 35)
113
114_NO_ACTIVE_TEST_DELAY_MS = 500
115
Tammo Spalinkdb8d7112012-03-13 18:54:37 +0800116
Hung-Te Lin6bb48552012-02-09 14:37:43 +0800117# ---------------------------------------------------------------------------
118# Client Library
119
Hung-Te Linf2f78f72012-02-08 19:27:11 +0800120
Hung-Te Lin4b0b61f2012-03-05 14:20:06 +0800121# TODO(hungte) Replace gtk_lock by gtk.gdk.lock when it's availble (need pygtk
122# 2.2x, and we're now pinned by 2.1x)
123class _GtkLock(object):
124 __enter__ = gtk.gdk.threads_enter
125 def __exit__(*ignored):
126 gtk.gdk.threads_leave()
127
128
129gtk_lock = _GtkLock()
130
131
Hung-Te Linf2f78f72012-02-08 19:27:11 +0800132def make_label(message, font=LABEL_FONT, fg=LIGHT_GREEN,
133 size=None, alignment=None):
Chun-Ta Linf33efa72012-03-15 18:56:38 +0800134 """Returns a label widget.
135
136 A wrapper for gtk.Label. The unit of size is pixels under resolution
137 _UI_SCREEN_WIDTH*_UI_SCREEN_HEIGHT.
138
139 @param message: A string to be displayed.
140 @param font: Font descriptor for the label.
141 @param fg: Foreground color.
142 @param size: Minimum size for this label.
143 @param alignment: Alignment setting.
144 @return: A label widget.
145 """
Hung-Te Linf2f78f72012-02-08 19:27:11 +0800146 l = gtk.Label(message)
147 l.modify_font(font)
148 l.modify_fg(gtk.STATE_NORMAL, fg)
149 if size:
Chun-Ta Linf33efa72012-03-15 18:56:38 +0800150 # Convert size according to the current resolution.
151 l.set_size_request(*convert_pixels(size))
Hung-Te Linf2f78f72012-02-08 19:27:11 +0800152 if alignment:
153 l.set_alignment(*alignment)
154 return l
155
156
Tai-Hsu Lin606685c2012-03-14 19:10:11 +0800157def make_status_row(init_prompt,
158 init_status,
159 label_size=_LABEL_STATUS_ROW_SIZE):
160 """Returns a widget that live updates prompt and status in a row.
161
162 Args:
163 init_prompt: The prompt label text.
164 init_status: The status label text.
165 label_size: The desired size of the prompt label and the status label.
166
167 Returns:
168 1) A dict whose content is linked by the widget.
169 2) A widget to render dict content in "prompt: status" format.
170 """
171 display_dict = {}
172 display_dict['prompt'] = init_prompt
173 display_dict['status'] = init_status
174
175 def prompt_label_expose(widget, event):
176 prompt = display_dict['prompt']
177 widget.set_text(prompt)
178
179 def status_label_expose(widget, event):
180 status = display_dict['status']
181 widget.set_text(status)
182 widget.modify_fg(gtk.STATE_NORMAL, LABEL_COLORS[status])
183
184 prompt_label = make_label(
185 init_prompt, size=label_size,
186 alignment=(0, 0.5))
187 delimiter_label = make_label(':', alignment=(0, 0.5))
188 status_label = make_label(
189 init_status, size=label_size,
190 alignment=(0, 0.5), fg=LABEL_COLORS[init_status])
191
192 widget = gtk.HBox()
193 widget.pack_end(status_label, False, False)
194 widget.pack_end(delimiter_label, False, False)
195 widget.pack_end(prompt_label, False, False)
196
197 status_label.connect('expose_event', status_label_expose)
198 prompt_label.connect('expose_event', prompt_label_expose)
199 return display_dict, widget
200
201
Chun-Ta Linf33efa72012-03-15 18:56:38 +0800202def convert_pixels(size):
203 """Converts a pair in pixel that is suitable for current resolution.
204
205 GTK takes pixels as its unit in many function calls. To maintain the
206 consistency of the UI in different resolution, a conversion is required.
207 Take current resolution and (_UI_SCREEN_WIDTH, _UI_SCREEN_HEIGHT) as
208 the original resolution, this function returns a pair of width and height
209 that is converted for current resolution.
210
211 Because pixels in negative usually indicates unspecified, no conversion
212 will be done for negative pixels.
213
214 In addition, the aspect ratio is not maintained in this function.
215
216 Usage Example:
217 width,_ = convert_pixels((20,-1))
218
219 @param size: A pair of pixels that designed under original resolution.
220 @return: A pair of pixels of (width, height) format.
221 Pixels returned are always integer.
222 """
223 return (int(float(size[0]) / _UI_SCREEN_WIDTH * gtk.gdk.screen_width()
224 if (size[0] > 0) else size[0]),
225 int(float(size[1]) / _UI_SCREEN_HEIGHT * gtk.gdk.screen_height()
226 if (size[1] > 0) else size[1]))
227
228
229def make_hsep(height=1):
230 """Returns a widget acts as a horizontal separation line.
231
232 The unit is pixels under resolution _UI_SCREEN_WIDTH*_UI_SCREEN_HEIGHT.
233 """
Hung-Te Linf2f78f72012-02-08 19:27:11 +0800234 frame = gtk.EventBox()
Chun-Ta Linf33efa72012-03-15 18:56:38 +0800235 # Convert height according to the current resolution.
236 frame.set_size_request(*convert_pixels((-1, height)))
Hung-Te Linf2f78f72012-02-08 19:27:11 +0800237 frame.modify_bg(gtk.STATE_NORMAL, SEP_COLOR)
238 return frame
239
240
241def make_vsep(width=1):
Chun-Ta Linf33efa72012-03-15 18:56:38 +0800242 """Returns a widget acts as a vertical separation line.
243
244 The unit is pixels under resolution _UI_SCREEN_WIDTH*_UI_SCREEN_HEIGHT.
245 """
Hung-Te Linf2f78f72012-02-08 19:27:11 +0800246 frame = gtk.EventBox()
Chun-Ta Linf33efa72012-03-15 18:56:38 +0800247 # Convert width according to the current resolution.
248 frame.set_size_request(*convert_pixels((width, -1)))
Hung-Te Linf2f78f72012-02-08 19:27:11 +0800249 frame.modify_bg(gtk.STATE_NORMAL, SEP_COLOR)
250 return frame
251
252
Hung-Te Linaf8ec542012-03-14 20:01:40 +0800253def make_countdown_widget(prompt=None, value=None, fg=LIGHT_GREEN):
254 if prompt is None:
255 prompt = 'time remaining / 剩餘時間: '
256 if value is None:
257 value = '%s' % FAIL_TIMEOUT
258 title = make_label(prompt, fg=fg, alignment=(1, 0.5))
259 countdown = make_label(value, fg=fg, alignment=(0, 0.5))
Hung-Te Linf2f78f72012-02-08 19:27:11 +0800260 hbox = gtk.HBox()
261 hbox.pack_start(title)
262 hbox.pack_start(countdown)
263 eb = gtk.EventBox()
264 eb.modify_bg(gtk.STATE_NORMAL, BLACK)
265 eb.add(hbox)
266 return eb, countdown
267
268
269def hide_cursor(gdk_window):
270 pixmap = gtk.gdk.Pixmap(None, 1, 1, 1)
271 color = gtk.gdk.Color()
272 cursor = gtk.gdk.Cursor(pixmap, pixmap, color, color, 0, 0)
273 gdk_window.set_cursor(cursor)
274
275
276def calc_scale(wanted_x, wanted_y):
277 (widget_size_x, widget_size_y) = factory.get_shared_data('test_widget_size')
278 scale_x = (0.9 * widget_size_x) / wanted_x
279 scale_y = (0.9 * widget_size_y) / wanted_y
280 scale = scale_y if scale_y < scale_x else scale_x
281 scale = 1 if scale > 1 else scale
282 factory.log('scale: %s' % scale)
283 return scale
284
285
286def trim(text, length):
287 if len(text) > length:
288 text = text[:length-3] + '...'
289 return text
290
291
Hung-Te Lin6dc799c2012-03-13 22:10:47 +0800292class InputError(ValueError):
293 """Execption for input window callbacks to change status text message."""
294 pass
295
296
Hung-Te Linbf545582012-02-15 17:08:07 +0800297def make_input_window(prompt=None,
298 init_value=None,
299 msg_invalid=None,
300 font=None,
301 on_validate=None,
302 on_keypress=None,
303 on_complete=None):
Tammo Spalinkdb8d7112012-03-13 18:54:37 +0800304 """Creates a widget to prompt user for a valid string.
Hung-Te Linbf545582012-02-15 17:08:07 +0800305
306 @param prompt: A string to be displayed. None for default message.
307 @param init_value: Initial value to be set.
308 @param msg_invalid: Status string to display when input is invalid. None for
309 default message.
310 @param font: Font specification (string or pango.FontDescription) for label
311 and entry. None for default large font.
312 @param on_validate: A callback function to validate if the input from user
Hung-Te Lin6dc799c2012-03-13 22:10:47 +0800313 is valid. None for allowing any non-empty input. Any ValueError or
314 ui.InputError raised during execution in on_validate will be displayed
315 in bottom status.
Hung-Te Linbf545582012-02-15 17:08:07 +0800316 @param on_keypress: A callback function when each keystroke is hit.
317 @param on_complete: A callback function when a valid string is passed.
318 None to stop (gtk.main_quit).
319 @return: A widget with prompt, input entry, and status label.
Chun-ta Lin2e7f44c2012-03-03 07:29:36 +0800320 In addition, a method called get_entry() is added to the widget to
321 provide controls on the entry.
Hung-Te Linbf545582012-02-15 17:08:07 +0800322 """
323 DEFAULT_MSG_INVALID = "Invalid input / 輸入不正確"
324 DEFAULT_PROMPT = "Enter Data / 輸入資料:"
325
326 def enter_callback(entry):
327 text = entry.get_text()
Hung-Te Lin6dc799c2012-03-13 22:10:47 +0800328 try:
329 if (on_validate and (not on_validate(text))) or (not text.strip()):
330 raise ValueError(msg_invalid)
Hung-Te Linbf545582012-02-15 17:08:07 +0800331 on_complete(text) if on_complete else gtk.main_quit()
Hung-Te Lin6dc799c2012-03-13 22:10:47 +0800332 except ValueError as e:
333 gtk.gdk.beep()
334 status_label.set_text('ERROR: %s' % e.message)
Hung-Te Linbf545582012-02-15 17:08:07 +0800335 return True
336
337 def key_press_callback(entry, key):
338 status_label.set_text('')
339 if on_keypress:
340 return on_keypress(entry, key)
341 return False
342
343 # Populate default parameters
344 if msg_invalid is None:
345 msg_invalid = DEFAULT_MSG_INVALID
346
347 if prompt is None:
348 prompt = DEFAULT_PROMPT
349
350 if font is None:
351 font = LABEL_LARGE_FONT
352 elif not isinstance(font, pango.FontDescription):
353 font = pango.FontDescription(font)
354
355 widget = gtk.VBox()
356 label = make_label(prompt, font=font)
357 status_label = make_label('', font=font)
358 entry = gtk.Entry()
359 entry.modify_font(font)
360 entry.connect("activate", enter_callback)
361 entry.connect("key_press_event", key_press_callback)
362 if init_value:
363 entry.set_text(init_value)
364 widget.modify_bg(gtk.STATE_NORMAL, BLACK)
365 status_label.modify_fg(gtk.STATE_NORMAL, RED)
366 widget.add(label)
367 widget.pack_start(entry)
368 widget.pack_start(status_label)
Chun-ta Lin2e7f44c2012-03-03 07:29:36 +0800369
370 # Method for getting the entry.
371 widget.get_entry = lambda : entry
Hung-Te Linbf545582012-02-15 17:08:07 +0800372 return widget
373
374
Hung-Te Linf2f78f72012-02-08 19:27:11 +0800375def make_summary_box(tests, state_map, rows=15):
Tammo Spalinkdb8d7112012-03-13 18:54:37 +0800376 '''Creates a widget display status of a set of test.
Hung-Te Linf2f78f72012-02-08 19:27:11 +0800377
378 @param tests: A list of FactoryTest nodes whose status (and children's
379 status) should be displayed.
380 @param state_map: The state map as provide by the state instance.
381 @param rows: The number of rows to display.
382 @return: A tuple (widget, label_map), where widget is the widget, and
383 label_map is a map from each test to the corresponding label.
384 '''
385 LABEL_EN_SIZE = (170, 35)
386 LABEL_EN_SIZE_2 = (450, 25)
387 LABEL_EN_FONT = pango.FontDescription('courier new extra-condensed 16')
388
389 all_tests = sum([list(t.walk(in_order=True)) for t in tests], [])
390 columns = len(all_tests) / rows + (len(all_tests) % rows != 0)
391
392 info_box = gtk.HBox()
393 info_box.set_spacing(20)
394 for status in (TestState.ACTIVE, TestState.PASSED,
395 TestState.FAILED, TestState.UNTESTED):
396 label = make_label(status,
397 size=LABEL_EN_SIZE,
398 font=LABEL_EN_FONT,
399 alignment=(0.5, 0.5),
400 fg=LABEL_COLORS[status])
401 info_box.pack_start(label, False, False)
402
403 vbox = gtk.VBox()
404 vbox.set_spacing(20)
405 vbox.pack_start(info_box, False, False)
406
407 label_map = {}
408
409 if all_tests:
410 status_table = gtk.Table(rows, columns, True)
411 for (j, i), t in izip(product(xrange(columns), xrange(rows)),
412 all_tests):
413 msg_en = ' ' * (t.depth() - 1) + t.label_en
414 msg_en = trim(msg_en, 12)
415 if t.label_zh:
416 msg = '{0:<12} ({1})'.format(msg_en, t.label_zh)
417 else:
418 msg = msg_en
419 status = state_map[t].status
420 status_label = make_label(msg,
421 size=LABEL_EN_SIZE_2,
422 font=LABEL_EN_FONT,
423 alignment=(0.0, 0.5),
424 fg=LABEL_COLORS[status])
425 label_map[t] = status_label
426 status_table.attach(status_label, j, j+1, i, i+1)
427 vbox.pack_start(status_table, False, False)
428
429 return vbox, label_map
430
431
432def run_test_widget(dummy_job, test_widget,
433 invisible_cursor=True,
434 window_registration_callback=None,
435 cleanup_callback=None):
436 test_widget_size = factory.get_shared_data('test_widget_size')
437
438 window = gtk.Window(gtk.WINDOW_TOPLEVEL)
439 window.modify_bg(gtk.STATE_NORMAL, BLACK)
440 window.set_size_request(*test_widget_size)
441
442 def show_window():
443 window.show()
444 window.window.raise_() # pylint: disable=E1101
445 gtk.gdk.pointer_grab(window.window, confine_to=window.window)
446 if invisible_cursor:
447 hide_cursor(window.window)
448
449 test_path = factory.get_current_test_path()
450
451 def handle_event(event):
452 if (event.type == Event.Type.STATE_CHANGE and
453 test_path and event.path == test_path and
454 event.state.visible):
455 show_window()
456
457 event_client = EventClient(
Hung-Te Lin6bb48552012-02-09 14:37:43 +0800458 callback=handle_event, event_loop=EventClient.EVENT_LOOP_GOBJECT_IO)
Hung-Te Linf2f78f72012-02-08 19:27:11 +0800459
460 align = gtk.Alignment(xalign=0.5, yalign=0.5)
461 align.add(test_widget)
462
463 window.add(align)
464 for c in window.get_children():
465 # Show all children, but not the window itself yet.
466 c.show_all()
467
468 if window_registration_callback is not None:
469 window_registration_callback(window)
470
471 # Show the window if it is the visible test, or if the test_path is not
472 # available (e.g., run directly from the command line).
473 if (not test_path) or (
474 TestState.from_dict_or_object(
475 factory.get_state_instance().get_test_state(test_path)).visible):
476 show_window()
477 else:
478 window.hide()
479
480 gtk.main()
481
482 gtk.gdk.pointer_ungrab()
483
484 if cleanup_callback is not None:
485 cleanup_callback()
486
487 del event_client
Hung-Te Lin6bb48552012-02-09 14:37:43 +0800488
489
490# ---------------------------------------------------------------------------
491# Server Implementation
492
493
494class Console(object):
495 '''Display a progress log. Implemented by launching an borderless
496 xterm at a strategic location, and running tail against the log.'''
497
498 def __init__(self, allocation):
Chun-Ta Lin734e96a2012-03-16 20:05:33 +0800499 # Specify how many lines and characters per line are displayed.
500 XTERM_DISPLAY_LINES = 13
501 XTERM_DISPLAY_CHARS = 120
502 # Extra space reserved for pixels between lines.
503 XTERM_RESERVED_LINES = 3
504
505 xterm_coords = '%dx%d+%d+%d' % (XTERM_DISPLAY_CHARS,
506 XTERM_DISPLAY_LINES,
507 allocation.x,
508 allocation.y)
509 xterm_reserved_height = gtk.gdk.screen_height() - allocation.y
510 font_size = int(float(xterm_reserved_height) / (XTERM_DISPLAY_LINES +
511 XTERM_RESERVED_LINES))
512 logging.info('xterm_reserved_height = %d' % xterm_reserved_height)
513 logging.info('font_size = %d' % font_size)
Hung-Te Lin6bb48552012-02-09 14:37:43 +0800514 logging.info('xterm_coords = %s', xterm_coords)
515 xterm_opts = ('-bg black -fg lightgray -bw 0 -g %s' % xterm_coords)
Chun-Ta Lin734e96a2012-03-16 20:05:33 +0800516 xterm_cmd = (
517 ['urxvt'] + xterm_opts.split() +
518 ['-fn', 'xft:DejaVu Sans Mono:pixelsize=%s' % font_size] +
519 ['-e', 'bash'] +
520 ['-c', 'tail -f "%s"' % factory.CONSOLE_LOG_PATH])
Hung-Te Lin6bb48552012-02-09 14:37:43 +0800521 logging.info('xterm_cmd = %s', xterm_cmd)
522 self._proc = subprocess.Popen(xterm_cmd)
523
524 def __del__(self):
525 logging.info('console_proc __del__')
526 self._proc.kill()
527
528
529class TestLabelBox(gtk.EventBox): # pylint: disable=R0904
530
Tammo Spalinkdb8d7112012-03-13 18:54:37 +0800531 def __init__(self, test):
Hung-Te Lin6bb48552012-02-09 14:37:43 +0800532 gtk.EventBox.__init__(self)
533 self.modify_bg(gtk.STATE_NORMAL, LABEL_COLORS[TestState.UNTESTED])
Tammo Spalinkdb8d7112012-03-13 18:54:37 +0800534 depth = len(test.get_ancestor_groups())
535 label_en_text = ' ' + ('..' * depth) + test.label_en
536 self._label_en = make_label(
537 label_en_text, size=_LABEL_EN_SIZE,
538 font=_LABEL_EN_FONT, alignment=(0, 0.5),
539 fg=_LABEL_UNTESTED_FG)
540 self._label_zh = make_label(
541 test.label_zh, size=_LABEL_ZH_SIZE,
542 font=_LABEL_ZH_FONT, alignment=(0.5, 0.5),
543 fg=_LABEL_UNTESTED_FG)
544 self._label_t = make_label(
545 '', size=_LABEL_T_SIZE, font=_LABEL_T_FONT,
546 alignment=(0.5, 0.5), fg=BLACK)
Hung-Te Lin6bb48552012-02-09 14:37:43 +0800547 hbox = gtk.HBox()
Tammo Spalinkdb8d7112012-03-13 18:54:37 +0800548 hbox.pack_start(self._label_en, False, False)
549 hbox.pack_start(self._label_zh, False, False)
550 hbox.pack_start(self._label_t, False, False)
551 vbox = gtk.VBox()
552 vbox.pack_start(hbox, False, False)
553 vbox.pack_start(make_hsep(), False, False)
554 self.add(vbox)
555 self._status = None
Hung-Te Lin6bb48552012-02-09 14:37:43 +0800556
Tammo Spalinkdb8d7112012-03-13 18:54:37 +0800557 def set_shortcut(self, shortcut):
558 if shortcut is None:
559 return
560 self._label_t.set_text('C-%s' % shortcut)
561 attrs = self._label_en.get_attributes() or pango.AttrList()
562 attrs.filter(lambda attr: attr.type == pango.ATTR_UNDERLINE)
563 index_hotkey = self._label_en.get_text().upper().find(shortcut.upper())
564 if index_hotkey != -1:
565 attrs.insert(pango.AttrUnderline(
566 pango.UNDERLINE_LOW, index_hotkey, index_hotkey + 1))
567 attrs.insert(pango.AttrWeight(
568 pango.WEIGHT_BOLD, index_hotkey, index_hotkey + 1))
569 self._label_en.set_attributes(attrs)
Hung-Te Lin6bb48552012-02-09 14:37:43 +0800570 self.queue_draw()
571
Tammo Spalinkdb8d7112012-03-13 18:54:37 +0800572 def update(self, status):
573 if self._status == status:
574 return
575 self._status = status
576 label_fg = (_LABEL_UNTESTED_FG if status == TestState.UNTESTED
577 else BLACK)
578 for label in [self._label_en, self._label_zh, self._label_t]:
579 label.modify_fg(gtk.STATE_NORMAL, label_fg)
580 self.modify_bg(gtk.STATE_NORMAL, LABEL_COLORS[status])
581 self.queue_draw()
582
583
584class TestDirectory(gtk.VBox):
585 '''Widget containing a list of tests, colored by test status.
586
587 This is the widget corresponding to the RHS test panel.
588
589 Attributes:
590 _label_map: Dict of test path to TestLabelBox objects. Should
591 contain an entry for each test that has been visible at some
592 time.
593 _visible_status: List of (test, status) pairs reflecting the
594 last refresh of the set of visible tests. This is used to
595 rememeber what tests were active, to allow implementation of
596 visual refresh only when new active tests appear.
597 _shortcut_map: Dict of keyboard shortcut key to test path.
598 Tracks the current set of keyboard shortcut mappings for the
599 visible set of tests. This will change when the visible
600 test set changes.
601 '''
602
603 def __init__(self):
604 gtk.VBox.__init__(self)
605 self.set_spacing(0)
606 self._label_map = {}
607 self._visible_status = []
608 self._shortcut_map = {}
609
610 def _get_test_label(self, test):
611 if test.path in self._label_map:
612 return self._label_map[test.path]
613 label_box = TestLabelBox(test)
614 self._label_map[test.path] = label_box
615 return label_box
616
617 def _remove_shortcut(self, path):
618 reverse_map = dict((v, k) for k, v in self._shortcut_map.items())
619 if path not in reverse_map:
620 logging.error('Removal of non-present shortcut for %s' % path)
621 return
622 shortcut = reverse_map[path]
623 del self._shortcut_map[shortcut]
624
625 def _add_shortcut(self, test):
626 shortcut = test.kbd_shortcut
627 if shortcut in self._shortcut_map:
628 logging.error('Shortcut %s already in use by %s; cannot apply to %s'
629 % (shortcut, self._shortcut_map[shortcut], test.path))
630 shortcut = None
631 if shortcut is None:
632 # Find a suitable shortcut. For groups, use numbers. For
633 # regular tests, use alpha (letters).
634 if isinstance(test, factory.TestGroup):
635 gen = (x for x in string.digits if x not in self._shortcut_map)
636 else:
637 gen = (x for x in test.label_en.lower() + string.lowercase
638 if x.isalnum() and x not in self._shortcut_map)
639 shortcut = next(gen, None)
640 if shortcut is None:
641 logging.error('Unable to find shortcut for %s' % test.path)
642 return
643 self._shortcut_map[shortcut] = test.path
644 return shortcut
645
646 def handle_xevent(self, dummy_src, dummy_cond,
647 xhandle, keycode_map, event_client):
648 for dummy_i in range(0, xhandle.pending_events()):
649 xevent = xhandle.next_event()
650 if xevent.type != X.KeyPress:
651 continue
652 keycode = xevent.detail
653 if keycode not in keycode_map:
654 logging.warning('Ignoring unknown keycode %r' % keycode)
655 continue
656 shortcut = keycode_map[keycode]
657 if shortcut not in self._shortcut_map:
658 logging.warning('Ignoring unbound shortcut %r' % shortcut)
659 continue
660 test_path = self._shortcut_map[shortcut]
661 event_client.post_event(Event(Event.Type.SWITCH_TEST,
662 key=test_path))
663 return True
664
665 def update(self, new_test_status):
666 '''Refresh the RHS test list to show current status and active groups.
667
668 Refresh the set of visible tests only when new active tests
669 arise. This avoids visual volatility when switching between
670 tests (intervals where no test is active). Also refresh at
671 initial startup.
672
673 Args:
674 new_test_status: A list of (test, status) tuples. The tests
675 order should match how they should be displayed in the
676 directory (rhs panel).
677 '''
678 old_active = set(t for t, s in self._visible_status
679 if s == TestState.ACTIVE)
680 new_active = set(t for t, s in new_test_status
681 if s == TestState.ACTIVE)
682 new_visible = set(t for t, s in new_test_status)
683 old_visible = set(t for t, s in self._visible_status)
684
685 if old_active and not new_active - old_active:
686 # No new active tests, so do not change the displayed test
687 # set, only update the displayed status for currently
688 # visible tests. Not updating _visible_status allows us
689 # to remember the last set of active tests.
690 for test, _ in self._visible_status:
691 status = test.get_state().status
692 self._label_map[test.path].update(status)
693 return
694
695 self._visible_status = new_test_status
696
697 new_test_map = dict((t.path, t) for t, s in new_test_status)
698
699 for test in old_visible - new_visible:
700 label_box = self._label_map[test.path]
701 logging.debug('removing %s test label' % test.path)
702 self.remove(label_box)
703 self._remove_shortcut(test.path)
704
705 new_tests = new_visible - old_visible
706
707 for position, (test, status) in enumerate(new_test_status):
708 label_box = self._get_test_label(test)
709 if test in new_tests:
710 shortcut = self._add_shortcut(test)
711 label_box = self._get_test_label(test)
712 label_box.set_shortcut(shortcut)
713 logging.debug('adding %s test label (sortcut %r, pos %d)' %
714 (test.path, shortcut, position))
715 self.pack_start(label_box, False, False)
716 self.reorder_child(label_box, position)
717 label_box.update(status)
718
719 self.show_all()
720
721
Hung-Te Lin6bb48552012-02-09 14:37:43 +0800722
723class UiState(object):
724
Tammo Spalinkdb8d7112012-03-13 18:54:37 +0800725 def __init__(self, test_widget_box, test_directory_widget, test_list):
Hung-Te Lin6bb48552012-02-09 14:37:43 +0800726 self._test_widget_box = test_widget_box
Tammo Spalinkdb8d7112012-03-13 18:54:37 +0800727 self._test_directory_widget = test_directory_widget
728 self._test_list = test_list
Hung-Te Lin6bb48552012-02-09 14:37:43 +0800729 self._transition_count = 0
Hung-Te Lin6bb48552012-02-09 14:37:43 +0800730 self._active_test_label_map = None
Tammo Spalinkdb8d7112012-03-13 18:54:37 +0800731 self.update_test_state()
Hung-Te Lin6bb48552012-02-09 14:37:43 +0800732
Tammo Spalinkdb8d7112012-03-13 18:54:37 +0800733 def update_test_state(self):
734 state_map = self._test_list.get_state_map()
735 active_tests = set(
736 t for t in self._test_list.walk()
737 if t.is_leaf() and state_map[t].status == TestState.ACTIVE)
738 active_groups = set(g for t in active_tests
739 for g in t.get_ancestor_groups())
Hung-Te Lin6bb48552012-02-09 14:37:43 +0800740
Tammo Spalinkdb8d7112012-03-13 18:54:37 +0800741 def filter_visible_test_state(tests):
742 '''List currently visible tests and their status.
Hung-Te Lin6bb48552012-02-09 14:37:43 +0800743
Tammo Spalinkdb8d7112012-03-13 18:54:37 +0800744 Visible means currently displayed in the RHS panel.
745 Visiblity is implied by being a top level test or having
746 membership in a group with at least one active test.
747
748 Returns:
749 A list of (test, status) tuples for all visible tests,
750 in the order they should be displayed.
751 '''
752 results = []
753 for test in tests:
754 if isinstance(test, factory.TestGroup):
755 results.append((test, TestState.UNTESTED))
756 if test not in active_groups:
757 continue
758 results += filter_visible_test_state(test.subtests)
759 else:
760 results.append((test, state_map[test].status))
761 return results
762
763 visible_test_state = filter_visible_test_state(self._test_list.subtests)
764 self._test_directory_widget.update(visible_test_state)
765
766 def remove_state_widget():
767 for child in self._test_widget_box.get_children():
768 self._test_widget_box.remove(child)
769 self._active_test_label_map = None
Hung-Te Lin6bb48552012-02-09 14:37:43 +0800770
771 if not active_tests:
772 # Display the "no active tests" widget if there are still no
773 # active tests after _NO_ACTIVE_TEST_DELAY_MS.
774 def run(transition_count):
775 if transition_count != self._transition_count:
776 # Something has happened
777 return False
778
779 self._transition_count += 1
Tammo Spalinkdb8d7112012-03-13 18:54:37 +0800780 remove_state_widget()
Hung-Te Lin6bb48552012-02-09 14:37:43 +0800781
782 self._test_widget_box.set(0.5, 0.5, 0.0, 0.0)
783 self._test_widget_box.set_padding(0, 0, 0, 0)
784 label_box = gtk.EventBox()
785 label_box.modify_bg(gtk.STATE_NORMAL, BLACK)
786 label = make_label('no active test', font=_OTHER_LABEL_FONT,
787 alignment=(0.5, 0.5))
788 label_box.add(label)
789 self._test_widget_box.add(label_box)
790 self._test_widget_box.show_all()
791
792 gobject.timeout_add(_NO_ACTIVE_TEST_DELAY_MS, run,
793 self._transition_count)
794 return
795
796 self._transition_count += 1
797
Tammo Spalinkdb8d7112012-03-13 18:54:37 +0800798 if any(t.has_ui for t in active_tests):
Hung-Te Lin6bb48552012-02-09 14:37:43 +0800799 # Remove the widget (if any) since there is an active test
800 # with a UI.
Tammo Spalinkdb8d7112012-03-13 18:54:37 +0800801 remove_state_widget()
Hung-Te Lin6bb48552012-02-09 14:37:43 +0800802 return
803
804 if (self._active_test_label_map is not None and
805 all(t in self._active_test_label_map for t in active_tests)):
806 # All active tests are already present in the summary, so just
807 # update their states.
808 for test, label in self._active_test_label_map.iteritems():
809 label.modify_fg(
810 gtk.STATE_NORMAL,
811 LABEL_COLORS[state_map[test].status])
812 return
813
Tammo Spalinkdb8d7112012-03-13 18:54:37 +0800814 remove_state_widget()
Hung-Te Lin6bb48552012-02-09 14:37:43 +0800815 # No active UI; draw summary of current test states
816 self._test_widget_box.set(0.5, 0.0, 0.0, 0.0)
817 self._test_widget_box.set_padding(40, 0, 0, 0)
818 vbox, self._active_test_label_map = make_summary_box(
Tammo Spalinkdb8d7112012-03-13 18:54:37 +0800819 [t for t in self._test_list.subtests
Hung-Te Lin6bb48552012-02-09 14:37:43 +0800820 if state_map[t].status == TestState.ACTIVE],
821 state_map)
822 self._test_widget_box.add(vbox)
823 self._test_widget_box.show_all()
824
Tammo Spalinkdb8d7112012-03-13 18:54:37 +0800825
826def grab_shortcut_keys(disp, event_handler, event_client):
827 # We want to receive KeyPress events
828 root = disp.screen().root
829 root.change_attributes(event_mask = X.KeyPressMask)
830 shortcut_set = set(string.lowercase + string.digits)
831 keycode_map = {}
832 for mod, shortcut in ([(X.ControlMask, k) for k in shortcut_set] +
833 [(X.Mod1Mask, 'Tab')]): # Mod1 = Alt
834 keysym = gtk.gdk.keyval_from_name(shortcut)
835 keycode = disp.keysym_to_keycode(keysym)
836 keycode_map[keycode] = shortcut
837 root.grab_key(keycode, mod, 1, X.GrabModeAsync, X.GrabModeAsync)
838 # This flushes the XGrabKey calls to the server.
839 for dummy_x in range(0, root.display.pending_events()):
840 root.display.next_event()
841 gobject.io_add_watch(root.display, gobject.IO_IN, event_handler,
842 root.display, keycode_map, event_client)
Hung-Te Lin6bb48552012-02-09 14:37:43 +0800843
844
845def main(test_list_path):
846 '''Starts the main UI.
847
848 This is launched by the autotest/cros/factory/client.
849 When operators press keyboard shortcuts, the shortcut
850 value is sent as an event to the control program.'''
851
852 test_list = None
853 ui_state = None
854 event_client = None
855
Hung-Te Lin6bb48552012-02-09 14:37:43 +0800856 def handle_key_release_event(_, event):
857 logging.info('base ui key event (%s)', event.keyval)
858 return True
859
860 def handle_event(event):
861 if event.type == Event.Type.STATE_CHANGE:
Tammo Spalinkdb8d7112012-03-13 18:54:37 +0800862 ui_state.update_test_state()
Hung-Te Lin6bb48552012-02-09 14:37:43 +0800863
864 test_list = factory.read_test_list(test_list_path)
865
866 window = gtk.Window(gtk.WINDOW_TOPLEVEL)
867 window.connect('destroy', lambda _: gtk.main_quit())
868 window.modify_bg(gtk.STATE_NORMAL, BLACK)
869
Tammo Spalinkdb8d7112012-03-13 18:54:37 +0800870 disp = Display()
871
Hung-Te Lin6bb48552012-02-09 14:37:43 +0800872 event_client = EventClient(
873 callback=handle_event,
874 event_loop=EventClient.EVENT_LOOP_GOBJECT_IO)
875
876 screen = window.get_screen()
877 if (screen is None):
878 logging.info('ERROR: communication with the X server is not working, ' +
879 'could not find a working screen. UI exiting.')
880 sys.exit(1)
881
882 screen_size_str = os.environ.get('CROS_SCREEN_SIZE')
883 if screen_size_str:
884 match = re.match(r'^(\d+)x(\d+)$', screen_size_str)
885 assert match, 'CROS_SCREEN_SIZE should be {width}x{height}'
886 screen_size = (int(match.group(1)), int(match.group(2)))
887 else:
888 screen_size = (screen.get_width(), screen.get_height())
889 window.set_size_request(*screen_size)
890
Tammo Spalinkdb8d7112012-03-13 18:54:37 +0800891 test_directory = TestDirectory()
Hung-Te Lin6bb48552012-02-09 14:37:43 +0800892
893 rhs_box = gtk.EventBox()
894 rhs_box.modify_bg(gtk.STATE_NORMAL, _LABEL_TROUGH_COLOR)
Tammo Spalinkdb8d7112012-03-13 18:54:37 +0800895 rhs_box.add(test_directory)
Hung-Te Lin6bb48552012-02-09 14:37:43 +0800896
897 console_box = gtk.EventBox()
Chun-Ta Lin734e96a2012-03-16 20:05:33 +0800898 console_box.set_size_request(*convert_pixels((-1, 180)))
Hung-Te Lin6bb48552012-02-09 14:37:43 +0800899 console_box.modify_bg(gtk.STATE_NORMAL, BLACK)
900
901 test_widget_box = gtk.Alignment()
902 test_widget_box.set_size_request(-1, -1)
903
904 lhs_box = gtk.VBox()
905 lhs_box.pack_end(console_box, False, False)
906 lhs_box.pack_start(test_widget_box)
907 lhs_box.pack_start(make_hsep(3), False, False)
908
909 base_box = gtk.HBox()
910 base_box.pack_end(rhs_box, False, False)
911 base_box.pack_end(make_vsep(3), False, False)
912 base_box.pack_start(lhs_box)
913
914 window.connect('key-release-event', handle_key_release_event)
915 window.add_events(gtk.gdk.KEY_RELEASE_MASK)
916
Tammo Spalinkdb8d7112012-03-13 18:54:37 +0800917 ui_state = UiState(test_widget_box, test_directory, test_list)
Hung-Te Lin6bb48552012-02-09 14:37:43 +0800918
919 window.add(base_box)
920 window.show_all()
921
Tammo Spalinkdb8d7112012-03-13 18:54:37 +0800922 grab_shortcut_keys(disp, test_directory.handle_xevent, event_client)
Hung-Te Lin6bb48552012-02-09 14:37:43 +0800923
924 hide_cursor(window.window)
925
926 test_widget_allocation = test_widget_box.get_allocation()
927 test_widget_size = (test_widget_allocation.width,
928 test_widget_allocation.height)
929 factory.set_shared_data('test_widget_size', test_widget_size)
930
931 dummy_console = Console(console_box.get_allocation())
932
933 event_client.post_event(Event(Event.Type.UI_READY))
934
935 logging.info('cros/factory/ui setup done, starting gtk.main()...')
936 gtk.main()
937 logging.info('cros/factory/ui gtk.main() finished, exiting.')
938
939
940if __name__ == '__main__':
Jon Salz14bcbb02012-03-17 15:11:50 +0800941 parser = OptionParser(usage='usage: %prog [options] TEST-LIST-PATH')
942 parser.add_option('-v', '--verbose', dest='verbose',
943 action='store_true',
944 help='Enable debug logging')
945 (options, args) = parser.parse_args()
Hung-Te Lin6bb48552012-02-09 14:37:43 +0800946
Jon Salz14bcbb02012-03-17 15:11:50 +0800947 if len(args) != 1:
948 parser.error('Incorrect number of arguments')
949
950 factory.init_logging('ui', verbose=options.verbose)
Hung-Te Lin6bb48552012-02-09 14:37:43 +0800951 main(sys.argv[1])