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