#!/usr/bin/env python # # Copyright (C) 2009 Christian Hergert # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . """ This script can parse a gdkrecord data file and output data dumps or graphs. """ import cairo import itertools import os import pangocairo import getopt import gtk import sys __author__ = 'Christian Hergert' __version__ = (0,1,0) GDK_EVENT_NAMES = { -1: 'GDK_NOTHING', 0: 'GDK_DELETE', 1: 'GDK_DESTROY', 2: 'GDK_EXPOSE', 3: 'GDK_MOTION_NOTIFY', 4: 'GDK_BUTTON_PRESS', 5: 'GDK_2BUTTON_PRESS', 6: 'GDK_3BUTTON_PRESS', 7: 'GDK_BUTTON_RELEASE', 8: 'GDK_KEY_PRESS', 9: 'GDK_KEY_RELEASE', 10: 'GDK_ENTER_NOTIFY', 11: 'GDK_LEAVE_NOTIFY', 12: 'GDK_FOCUS_CHANGE', 13: 'GDK_CONFIGURE', 14: 'GDK_MAP', 15: 'GDK_UNMAP', 16: 'GDK_PROPERTY_NOTIFY', 17: 'GDK_SELECTION_CLEAR', 18: 'GDK_SELECTION_REQUEST', 19: 'GDK_SELECTION_NOTIFY', 20: 'GDK_PROXIMITY_IN', 21: 'GDK_PROXIMITY_OUT', 22: 'GDK_DRAG_ENTER', 23: 'GDK_DRAG_LEAVE', 24: 'GDK_DRAG_MOTION', 25: 'GDK_DRAG_STATUS', 26: 'GDK_DROP_START', 27: 'GDK_DROP_FINISHED', 28: 'GDK_CLIENT_EVENT', 29: 'GDK_VISIBILITY_NOTIFY', 30: 'GDK_NO_EXPOSE', 31: 'GDK_SCROLL', 32: 'GDK_WINDOW_STATE', 33: 'GDK_SETTING', 34: 'GDK_OWNER_CHANGE', 35: 'GDK_GRAB_BROKEN', 36: 'GDK_DAMAGE', } EVENT_BEGIN, \ EVENT_END, \ EVENT_XTIME, \ EVENT_TYPE, \ EVENT_WINDOW, \ EVENT_DATA = range(6) def take(n, iterable): "Return first n items of the iterable as a list" return list(itertools.islice(iterable, n)) def parseFile(name, callback): try: f = file(name) except Exception, ex: print >> sys.stderr, 'Could not open %s: %s' % (f.name, str(ex)) print >> sys.stderr, '' return False pos = 0 f.seek(0, 2) fsize = f.tell() f.seek(0) op = [None, None, None, None, None, None] cur = '' while pos < fsize: c = f.read(1) # looking for begin if op[0] is None: if c != '|': cur += c else: op[0] = float(cur) cur = '' # looking for end elif op[1] is None: if c != '|': cur += c else: op[1] = float(cur) cur = '' # looking for xtime elif op[2] is None: if c != '|': cur += c else: # xtime is in milliseconds op[2] = float(cur) / 1000 cur = '' # looking for type elif op[3] is None: if c != '|': cur += c else: op[3] = int(cur) cur = '' # looking for window elif op[4] is None: if c != '|': cur += c else: op[4] = cur cur = '' # looking for len elif op[5] is None: if c != '|': cur += c else: length = int(cur) cur = '' if length: op[5] = f.read(length) pos += length callback(op) op = [None, None, None, None, None, None] pos += 1 def eventTypeName(event): eventType = event[EVENT_TYPE] return GDK_EVENT_NAMES[eventType] def eventLatency(event): begin, xtime = event[EVENT_BEGIN], event[EVENT_XTIME] if xtime > 0: return (xtime - begin) return 0.0 def eventTotal(event): return event[EVENT_END] - event[EVENT_BEGIN] def dumpFile(name, format='plain', stdout=sys.stdout): def parseCallbackPlain(event): print >> stdout, '%f - %f [%s]' % ( event[EVENT_BEGIN], event[EVENT_END], eventTypeName(event)) print >> stdout, '\tWindow.....: %s' % event[EVENT_WINDOW] print >> stdout, '\tBegin......: %f' % event[EVENT_BEGIN] print >> stdout, '\tEnd........: %f' % event[EVENT_END] print >> stdout, '\tTotal......: %f' % eventTotal(event) print >> stdout, '\tLatency....: %f' % eventLatency(event) print >> stdout, '\tExtra......: %s' % event[EVENT_DATA] print >> stdout, '' def parseCallbackCsv(event): print >> stdout, '%f,%f,%f,%d,%s,%s' % ( event[EVENT_BEGIN], event[EVENT_END], event[EVENT_XTIME], event[EVENT_TYPE], event[EVENT_WINDOW], event[EVENT_DATA]) def parseCallbackXml(event, xml): from xml.etree.ElementTree import Element e = Element('event') e.attrib['begin'] = '%f' % event[EVENT_BEGIN] e.attrib['end'] = '%f' % event[EVENT_END] e.attrib['xtime'] = '%f' % event[EVENT_XTIME] e.attrib['type'] = '%d' % event[EVENT_TYPE] e.attrib['window'] = '%s' % event[EVENT_WINDOW] e.attrib['extra'] = '%s' % event[EVENT_DATA] xml.append(e) def parseCallbackJson(event, events): events.insert(0, event) if format == 'plain': parseFile(name, parseCallbackPlain) elif format == 'csv': print >> stdout, 'begin,end,xtime,type,window,extra' parseFile(name, parseCallbackCsv) elif format == 'xml': from xml.etree import ElementTree x = ElementTree.ElementTree() r = x._root = ElementTree.Element('events') parseFile(name, lambda e: parseCallbackXml(e,r)) x.write(stdout) elif format == 'json': from json import dumps i = [] parseFile(name, lambda e: parseCallbackJson(e,i)) i.reverse() print >> stdout, dumps(i) else: raise ValueError, 'Unknown format %s' % format def colorFor(event): """Returns color based on event type.""" def parse(htmlhex): if htmlhex[0] != '#': return (0, 0, 0) htmlhex = htmlhex[1:] return (int(htmlhex[0:2], 16) / float(0xff), int(htmlhex[2:4], 16) / float(0xff), int(htmlhex[4:6], 16) / float(0xff)) if event[EVENT_TYPE] == 2: return parse('#cc0000') elif event[EVENT_TYPE] == 13: return parse('#75507b') elif event[EVENT_TYPE] == 14: return parse('#ad7fa8') elif event[EVENT_TYPE] == 15: return parse('#ad7fa8') elif event[EVENT_TYPE] == 4: return parse('#8ae234') elif event[EVENT_TYPE] == 7: return parse('#4e9a06') elif event[EVENT_TYPE] == 10: return parse('#edd400') return (1, 1, 1) def graphFile(name, outname, format='png', windowId=None): events = [] layouts = [] s = cairo.ImageSurface(cairo.FORMAT_ARGB32, 1, 1) c = cairo.Context(s) p = pangocairo.CairoContext(c) state = {'height': 0, 'width': 0} def incrementCount(event): l = p.create_layout() format = event[EVENT_DATA] or '' l.set_text(format) w, h = l.get_pixel_size() if w > state['width']: state['width'] = w state['height'] += h events.insert(0, event) layouts.insert(0, l) if windowId: parseFile(name, lambda e: e[EVENT_WINDOW] == windowId and incrementCount(e)) else: parseFile(name, incrementCount) events.reverse() layouts.reverse() TIME_WIDTH = 200 BAR_WIDTH = 50 JOIN_WIDTH = 150 BEGIN_WIDTH = 100 END_WIDTH = 100 WINDOW_WIDTH = 100 TYPE_WIDTH = 200 width = TIME_WIDTH + \ BAR_WIDTH + \ JOIN_WIDTH + \ BEGIN_WIDTH + \ END_WIDTH + \ TYPE_WIDTH + \ WINDOW_WIDTH + \ state['width'] height = state['height'] extra_x = width - state['width'] s = cairo.ImageSurface(cairo.FORMAT_ARGB32, width, height + 20) c = cairo.Context(s) p = pangocairo.CairoContext(c) y = 0 # fill background black c.rectangle(0, 0, width, height + 20) c.set_source_rgb(0, 0, 0) c.fill() c.set_source_rgb(1, 1, 1) c.set_line_width(.5 * c.get_line_width()) # generate time blocks l = p.create_layout() time_block_offset = height / 10 begin = events[0][EVENT_BEGIN] end = events[-1][EVENT_END] total_time = end - begin for i in range(0, 11): offset = time_block_offset * i l.set_text('%0.6f' % (begin + ((total_time / 10.0) * i))) _, h = l.get_pixel_size() c.move_to(10, min(offset, height - h)) p.show_layout(l) for event, layout in zip(events, layouts): ev_begin = event[EVENT_BEGIN] - begin bar_y = (ev_begin / total_time) * height _, h = layout.get_pixel_size() y += h c.set_source_rgb(*colorFor(event)) c.move_to(100, bar_y) c.line_to(150, bar_y) c.line_to(300, y + (h / 2)) c.stroke() c.move_to(310, y) format = '%0.5f - %0.5f' % (event[EVENT_BEGIN], event[EVENT_END]) l.set_text(format) p.show_layout(l) c.move_to(310 + TIME_WIDTH, y) l.set_text(eventTypeName(event)[4:]) p.show_layout(l) c.move_to(extra_x - WINDOW_WIDTH, y) l.set_text(event[EVENT_WINDOW]) p.show_layout(l) c.move_to(extra_x, y) p.show_layout(layout) s.write_to_png(outname) class ExposeView(gtk.Window): model = None treeview = None selected_window = None def __init__(self): gtk.Window.__init__(self) self.props.title = 'Eposure Viewer' self.set_default_size(640, 480) vpaned = gtk.VPaned() self.add(vpaned) # window-id, events self.model = gtk.ListStore(str, object) self.treeview = gtk.TreeView() self.treeview.set_model(self.model) self.treeview.show() scroller = gtk.ScrolledWindow() scroller.set_policy(gtk.POLICY_AUTOMATIC, gtk.POLICY_AUTOMATIC) scroller.add(self.treeview) vpaned.add1(scroller) col = gtk.TreeViewColumn() col.props.title = 'Name' col.props.expand = True name_cell = gtk.CellRendererText() col.pack_start(name_cell, True) col.add_attribute(name_cell, 'text', 0) self.treeview.append_column(col) col = gtk.TreeViewColumn() col.props.title = 'Events' count_cell = gtk.CellRendererText() col.pack_start(count_cell, True) def row_func(col, cell, model, iter): cell.props.text = '%d' % len(model.get_value(iter, 1)) col.set_cell_data_func(count_cell, row_func) self.treeview.append_column(col) self.treeview.get_selection().connect('changed', self.row_changed) vbox = gtk.VBox() vpaned.add2(vbox) vbox.show() self.scale = gtk.HScale() self.scale.set_digits(0) self.scale.props.adjustment.set_all(1, 1, 1, 1, 1) self.scale.props.adjustment.connect('value-changed', lambda *_: self.drawing_area.queue_draw()) vbox.pack_start(self.scale, False, True, 0) self.drawing_area = gtk.DrawingArea() self.drawing_area.props.app_paintable = True self.drawing_area.connect('expose-event', self.expose_area) vbox.pack_start(self.drawing_area, True, True, 0) self.show_all() def expose_area(self, drawing_area, event): if not self.selected_window: return def getAreaFrom(event): data = event[EVENT_DATA] area = [0,0,0,0] for chunk in [a.split('=') for a in data.split(',')]: if len(chunk) == 2: if chunk[0] == 'x': area[0] = int(chunk[1]) elif chunk[0] == 'y': area[1] = int(chunk[1]) elif chunk[0] == 'w': area[2] = int(chunk[1]) elif chunk[0] == 'h': area[3] = int(chunk[1]) return area def getWindowSize(event): data = event[EVENT_DATA] if data: match = [a for a in data.split(',') if a.startswith('window=')] if match: return [int(i) for i in match[0].replace('window=','').split('x')] return 0, 0 n = self.scale.props.adjustment.value area = (0, 0, 0, 0) w, h = 0, 0 for event in take(n, self.selected_events): area = getAreaFrom(event) w, h = getWindowSize(event) cr = drawing_area.window.cairo_create() cr.rectangle(*area) cr.set_source_rgb(1, 0, 0) cr.fill() cr.rectangle(0, 0, w, h) cr.set_source_rgb(0, 0, 0) cr.stroke() def set_recorded_events(self, events): self.model.clear() windows = {} for event in events: window_id = event[EVENT_WINDOW] if window_id not in windows: windows[window_id] = [] windows[window_id].insert(0, event) for _,events in windows.iteritems(): events.reverse() for window_id,events in windows.iteritems(): self.model.append(row=(window_id, events)) self.drawing_area.queue_draw() def row_changed(self, selection): self.drawing_area.queue_draw() model, iter = selection.get_selected() if not iter: self.selected_window = None return self.selected_window = model.get_value(iter, 0) self.selected_events = model.get_value(iter, 1) length = len(self.selected_events) self.scale.props.adjustment.set_upper(length) self.scale.props.adjustment.props.value = 1 def viewExposures(filenames): w = ExposeView() w.connect('delete-event', lambda *_: gtk.main_quit()) w.show() events = [] def parseCallback(event): if eventTypeName(event) in ('GDK_EXPOSE',): events.insert(0, event) for filename in filenames: parseFile(filename, parseCallback) events.reverse() w.set_recorded_events(events) gtk.main() def usage(argv=sys.argv, stdout=sys.stdout): print >> stdout, """Usage: %s [OPTION...] COMMAND FILENAME... Analyze and graph gdkrecord data files. Application Options: -h, --help Show help options --version Show application version -d, --dump-format=FORMAT Specify format to dump records [plain, json, xml, csv] -g, --graph-format=png Specify format to output graphs [png] -w, --window=ID Limit events to window [Graph only] Commands: dump Dump recording information graph Graph recording information exposures Visualize window exposures """ % argv[0] return 0 def main(argv=sys.argv, stdout=sys.stdout, stderr=sys.stderr): """gdkrecord-tool entry point.""" try: shortOpts = 'hd:g:w:' longOpts = ['help', 'version', 'dump-format=', 'graph-format=', 'window='] opts, args = getopt.getopt(argv[1:], shortOpts, longOpts) except getopt.GetoptError, ex: print >> stderr, str(ex) print >> stderr, '' return usage(argv, stderr) dumpFormat = 'plain' graphFormat = 'png' windowId = None for o,a in opts: if o in ('-h', '--help'): return usage() or 0 elif o in ('--version',): print 'gdkrecord-tool Version %s' % ('.'.join([str(i) for i in __version__])) return 0 elif o in ('-d', '--dump-format'): if a not in ('plain', 'xml','json','csv'): print >> stderr, '--dump-format %s is not supported' % a print >> stderr, '' return usage() or 1 dumpFormat = a elif o in ('-g', '--graph-format'): if a not in ('png',): print >> stderr, '--graph-format %s is not supported' % a print >> stderr, '' return usage() or 1 graphFormat = a elif o in ('-w', '--window'): windowId = a if not len(args): return usage() command, args = args[0], args[1:] if command == 'dump': for name in args: if not os.path.isfile(name): print >> stderr, 'Invalid file "%s"' % name return 1 dumpFile(name, dumpFormat, stdout) elif command == 'graph': for name in args: if not os.path.isfile(name): print >> stderr, 'Invalid file "%s"' % name return 1 outname = '%s.%s' % (name, graphFormat) graphFile(name, outname, graphFormat, windowId) elif command == 'exposures': viewExposures(args) else: print >> stderr, 'Command', command, 'is not supported' print >> stderr, '' return usage(argv, stderr) or 1 if __name__ == '__main__': sys.exit(main())