diff --git a/lglpy/android/utils.py b/lglpy/android/utils.py index 83259a6..3509cf4 100644 --- a/lglpy/android/utils.py +++ b/lglpy/android/utils.py @@ -241,9 +241,8 @@ def is_package_32bit(cls, conn: ADBConnect, package: str) -> bool: command = f'pm dump {package} | grep primaryCpuAbi' log = conn.adb_run(command) pattern = re.compile('primaryCpuAbi=(\\S+)') - match = pattern.search(log) - if match: + if match := pattern.search(log): log_abi = match.group(1) if log_abi != 'null': preferred_abi = log_abi diff --git a/lglpy/timeline/data/processed_trace.py b/lglpy/timeline/data/processed_trace.py index 7aa8cc5..d3557ea 100644 --- a/lglpy/timeline/data/processed_trace.py +++ b/lglpy/timeline/data/processed_trace.py @@ -26,11 +26,15 @@ single combined representation. ''' +import re from typing import Optional, Union from .raw_trace import RawTrace, RenderstageEvent, MetadataWork, \ MetadataRenderPass, MetadataDispatch, MetadataBufferTransfer, \ - MetadataImageTransfer, GPUStreamID + MetadataImageTransfer, GPUStreamID, GPUStageID + +LABEL_HEURISTICS = True +LABEL_MAX_LEN = 60 class GPUWorkload: @@ -47,6 +51,11 @@ class GPUWorkload: label_stack: Application debug label stack. ''' + FRAME_LABEL = re.compile(r'^Frame (\d+)$') + PARENS = re.compile(r'(\(.*\))') + RESOLUTION = re.compile(r'\d+x\d+') + WHITESPACE = re.compile(r'\s\s+') + def __init__( self, event: RenderstageEvent, metadata: Optional[MetadataWork]): ''' @@ -66,10 +75,96 @@ def __init__( # Common data we get from the layer metadata self.frame = None self.label_stack = None + self.parsed_label_name = None + if metadata: self.frame = metadata.frame self.label_stack = metadata.label_stack + def get_label_name(self) -> Optional[str]: + ''' + Get a cleaned up label name for a workload. + + Warning: The heuristics here are not robust. + + Returns: + A modified label for use in the UI. + ''' + # No label to parse + if not self.label_stack: + return None + + # Cached label already parsed + if self.parsed_label_name is not None: + return self.parsed_label_name + + if not LABEL_HEURISTICS: + return self.label_stack[-1] + + # Create a copy we can edit ... + labels = list(self.label_stack) + + # Heuristic to remove app-concatenated leaf nodes in UE + if 'Scene.' in labels[-1]: + del labels[-1] + + # Pop off low value root nodes in UE captures + if labels and self.FRAME_LABEL.match(labels[0]): + del labels[0] + + if labels and labels[0] == 'Scene': + del labels[0] + + # String substitutions + for i, label in enumerate(labels): + label = self.PARENS.sub('', label) + label = self.RESOLUTION.sub('', label) + label = self.WHITESPACE.sub(' ', label) + label = label.replace('Light::', '') + labels[i] = label.strip() + + # Stack prefix substitutions + for i, label in enumerate(labels): + for j in range(i + 1, len(labels)): + next_label = labels[j] + if not next_label.startswith(label): + break + labels[j] = next_label[len(label):].strip() + + # Remove labels that are now empty + labels = list(filter(bool, labels)) + + if not labels: + label = '' + else: + label = '.'.join(labels) + + if len(label) > LABEL_MAX_LEN: + half_max = LABEL_MAX_LEN // 2 + prefix = label[0:half_max] + postfix = label[-half_max:] + label = f'{prefix}...{postfix}' + + self.parsed_label_name = label + return self.parsed_label_name + + def get_workload_name(self) -> str: + ''' + Get a name for the workload. + + This is based on the application debug label if there is one, but + with some heuristics to try and clean is up ... + + Returns: + Returns the label for use in the UI. + ''' + if not self.label_stack: + return GPUStageID.get_ui_name(self.stage) + + label = self.get_label_name() + assert label + return label + def get_long_label(self) -> str: ''' Get the long form label for this workload. @@ -177,8 +272,8 @@ def get_long_label(self) -> str: ''' lines = [] - if self.label_stack: - lines.append(self.label_stack[-1]) + if label_name := self.get_label_name(): + lines.append(label_name) if self.draw_call_count < 0: draw_str = 'Unknown draws' @@ -243,8 +338,8 @@ def get_long_label(self) -> str: ''' lines = [] - if self.label_stack: - lines.append(self.label_stack[-1]) + if label_name := self.get_label_name(): + lines.append(label_name) lines.append(self.get_short_label()) return '\n'.join(lines) @@ -309,8 +404,8 @@ def get_long_label(self) -> str: ''' lines = [] - if self.label_stack: - lines.append(self.label_stack[-1]) + if label_name := self.get_label_name(): + lines.append(label_name) # If indirect then show a placeholder if self.pixel_count == -1: @@ -365,8 +460,8 @@ def get_long_label(self) -> str: ''' lines = [] - if self.label_stack: - lines.append(self.label_stack[-1]) + if label_name := self.get_label_name(): + lines.append(label_name) # If indirect then show a placeholder if self.byte_count == -1: diff --git a/lglpy/timeline/data/raw_trace.py b/lglpy/timeline/data/raw_trace.py index 1d58a5c..9ac458e 100644 --- a/lglpy/timeline/data/raw_trace.py +++ b/lglpy/timeline/data/raw_trace.py @@ -38,7 +38,7 @@ JSONType = Any -class GPUStreamID(enum.Enum): +class GPUStreamID(enum.IntEnum): ''' Symbolic mapping of known GPU scheduling stream IDs. @@ -80,7 +80,7 @@ def get_ui_name(cls, stream_id) -> str: return human_names[stream_id] -class GPUStageID(enum.Enum): +class GPUStageID(enum.IntEnum): ''' Symbolic mapping of known GPU workload stage IDs. diff --git a/lglpy/timeline/drawable/css.py b/lglpy/timeline/drawable/css.py index 2fc5269..196675c 100644 --- a/lglpy/timeline/drawable/css.py +++ b/lglpy/timeline/drawable/css.py @@ -24,7 +24,7 @@ This module contains a parser of a basic CSS-like style sheet that we use for styling the user interface. -Unlike HTML, we have no standard implicit hierarchy we can use to implement +Unlike HTML, we have no standard implicit DOM that we can use to implement style inheritance, so our CSS self-defines the parent-child hierarchical relationships in the CSS itself. For example, in the node below we define mtv-core as the parent style of the tlv-core style: @@ -52,8 +52,11 @@ difficult to specify a fallback when a font isn't available for OS portability. ''' -import re import os +import re +from typing import Optional + + import cairo @@ -112,26 +115,18 @@ class CSSStylesheet(dict): node does not provide the value itself. All nodes inherit from a default root node which defines a default value for all exposed style keys. - TODO: snake_case and CONSTANTS - Attributes: - reComment: regex rule - Regex for matching comments. - reNodeDecl: regex rule - Regex for matching node declarations. - reNodeEndDecl: regex rule - Regex for matching the end of node declarations. - reColorDecl: regex rule - Regex for matching colors. - reDashDecl: regex rule - Regex for matching dashes. - reFloatDecl: regex rule - Regex for matching floats. + re_comment: Regex for matching comments. + re_node_decl: Regex for matching node declarations. + re_node_end_decl: Regex for matching the end of node declarations. + re_color_decl: Regex for matching colors. + re_dash_decl: Regex for matching dashes. + re_float_decl: Regex for matching floats. ''' - reComment = re.compile(r'/\*(.*?)\*/') + re_comment = re.compile(r'/\*(.*?)\*/') - reNodeDecl = re.compile( + re_node_decl = re.compile( r''' ^\s* # Start new line, ignore whitespace (?:\[ # Parent node @@ -148,9 +143,9 @@ class CSSStylesheet(dict): re.VERBOSE ) - reNodeEndDecl = re.compile(r'^\s*}\s*$') + re_node_end_decl = re.compile(r'^\s*}\s*$') - reColorDecl = re.compile( + re_color_decl = re.compile( r''' ^\s* # Start a new line ignore whitespace ((?:fill|line|font)-color):\s* # Mandatory name @@ -163,7 +158,7 @@ class CSSStylesheet(dict): re.VERBOSE ) - reDashDecl = re.compile( + re_dash_decl = re.compile( r''' ^\s* # Start a new line ignore whitespace (line-dash):\s* # Mandatory name @@ -174,7 +169,7 @@ class CSSStylesheet(dict): re.VERBOSE ) - reFloatDecl = re.compile( + re_float_decl = re.compile( r''' ^\s* # Start a new line ignore whitespace (line-width|font-size):\s* # Mandatory name @@ -185,7 +180,9 @@ class CSSStylesheet(dict): re.VERBOSE ) - def __init__(self, css_file=None, css_string=None): + def __init__( + self, css_file: Optional[str] = None, + css_string: Optional[str] = None): ''' Create a new stylesheet from either a file or a string. Only one of these two options can be used at a time! @@ -212,15 +209,16 @@ def __init__(self, css_file=None, css_string=None): with open(css_file, 'r', encoding='utf-8') as handle: css_string = handle.read() + assert css_string self.parse_string(css_string, css_file) - def parse_string(self, css_string, file_name): + def parse_string(self, css_string: str, file_name: Optional[str]): ''' Populate stylesheet from a CSS string. Args: - css_string: CSS string. - file_name : File name, or None if not loaded from file. + css_string: The CSS data. + file_name: File name if loaded from file. Raises: ValueError: Parse errors are encountered. @@ -232,58 +230,53 @@ def parse_string(self, css_string, file_name): # Parse line-wise try: - current_nodes = [] + current_nodes: list[CSSNode] = [] for line_no, line in enumerate(lines): # String out any comments and whitespace - line = self.reComment.sub('', line).strip() + line = self.re_comment.sub('', line).strip() # Skip blank lines if not line: continue # Handle node declarations - match = self.reNodeDecl.match(line) - if match: + if match := self.re_node_decl.match(line): parent = match.group(1) if match.group(1) else '' - nodes = [x.strip() for x in match.group(2).split(',')] - nodes = [self.get_node(x, parent) for x in nodes] + parts = [x.strip() for x in match.group(2).split(',')] + nodes = [self.get_node(x, parent) for x in parts] ending = match.group(3) if not ending: current_nodes = nodes continue # ... and terminations - match = self.reNodeEndDecl.match(line) - if match: - current_nodes = None + if match := self.re_node_end_decl.match(line): + current_nodes = [] continue # Handle node field declarations for colors - match = self.reColorDecl.match(line) - if match: + if match := self.re_color_decl.match(line): key = match.group(1) - value = CSSColor(match.group(2)) + color_value = CSSColor(match.group(2)) for node in current_nodes: - node[key] = value + node[key] = color_value continue # Handle node field declarations for floats - match = self.reFloatDecl.match(line) - if match: + if match := self.re_float_decl.match(line): key = match.group(1) - value = float(match.group(2)) + float_value = float(match.group(2)) for node in current_nodes: - node[key] = value + node[key] = float_value continue # Handle node field declarations for line dashes - match = self.reDashDecl.match(line) - if match: + if match := self.re_dash_decl.match(line): key = match.group(1) - value = CSSDash(match.group(2)) + dash_value = CSSDash(match.group(2)) for node in current_nodes: - node[key] = value + node[key] = dash_value continue # If we get here this line is an unknown so raise an error @@ -300,7 +293,7 @@ def parse_string(self, css_string, file_name): msg = f'CSS parent syntax error: "{line}" @ line {line_no + 1}{msg}' raise ValueError(msg) from exc - def get_node(self, name, parent='', create=True): + def get_node(self, name, parent='', create=True) -> 'CSSNode': ''' Fetch a CSS node, optionally creating it if it does not exist. @@ -342,12 +335,12 @@ class CSSColor(tuple): tuples. Created instances will not be instances of CSSColor. ''' - def __new__(cls, color='none'): + def __new__(cls, color: str = 'none'): ''' Create a new CSSColor tuple. Args: - color: The color string, or `none` if no color required. + color: The color string, or "none" if no color required. ''' if color == 'none': return None @@ -377,7 +370,7 @@ class CSSFont(tuple): tuples. Created instances will not be instances of CSSFont. ''' - def __new__(cls, face='sans'): + def __new__(cls, face: str = 'sans'): ''' Create a new CSSFont tuple. diff --git a/lglpy/timeline/drawable/drawable_channel.py b/lglpy/timeline/drawable/drawable_channel.py index 677b5e8..063561b 100644 --- a/lglpy/timeline/drawable/drawable_channel.py +++ b/lglpy/timeline/drawable/drawable_channel.py @@ -167,6 +167,15 @@ def __iter__(self): ''' yield from self.objects + def __len__(self): + ''' + Number of objects in this channel. + + Returns: + The number of objects stored. + ''' + return len(self.objects) + def each_object(self, obj_filter=None, ws_range=None): ''' Generator which yields filtered objects from the channel in list order. @@ -321,75 +330,3 @@ def draw(self, gc, vp, ws_range=None): # Lowest object we need is the first object to cover next WS next_ws = vp.coverage_cull_next_ws_x index = bisect_left(self.objects_x_max, next_ws, index + 1) - - -class DrawableChannelFrameMarkers(DrawableChannel): - ''' - A container for world-space frame boundary markers. - - This container is a specialized channel designed for rendering frame - boundary lines and labels on the trace. Unlike normal line markers - such as dependencies, frame markers are useful to see when zoomed out of - the timeline, but quickly overwhelm it if you are zoomed out a long way - (especially with the label rendering, as the labels all overlap). - - To avoid this we implement dynamic culling of both lines and labels to - keep the volume of information on screen at a useful level. - ''' - - def draw(self, gc, vp, ws_range=None): - ''' - Draw this channel onto a Cairo canvas, using the given viewport. - - Args: - gc: Cairo graphics context. - vp: visible viewport - ws_range: world-space extents withing which objects should be - returned, or None if no filtering. Note that this is just an - optimization hint, and objects outside of this range may be - drawn. - ''' - del ws_range # unused - - # Lowest object we need is the last to cover first WS value - start_index = bisect.bisect_left(self.objects_x_max, vp.ws.min_x) - - # Highest object we need is the first to cover the last WS value - end_index = bisect.bisect_right(self.objects_x_min, vp.ws.max_x) - end_index = min(end_index, len(self.objects) - 1) - - width = vp.cs.max_x - vp.cs.min_x - - # Allow labels on every 60 pixels and lines every 25 - label_threshold = width / 60 - line_threshold = width / 25 - - # Start with the minimum object - line_count = 0 - - # Iterate all frame markers, rendering conservatively - index = start_index - while index <= end_index: - user_object = self.objects[index] - index += 1 - - # Render only the less lines this time through - if isinstance(user_object, WorldDrawableLine): - line_count += 1 - if line_count % 10 == 0: - user_object.draw(gc, vp) - - # If more lines than line threshold then we're done - if line_count > line_threshold: - return - - # Iterate all frame markers, rendering extras which we need - index = start_index - while index <= end_index: - user_object = self.objects[index] - index += 1 - - # Render extras if needed - if isinstance(object, WorldDrawableLine) or \ - (line_count < label_threshold): - user_object.draw(gc, vp) diff --git a/lglpy/timeline/drawable/primitive_rectangle.py b/lglpy/timeline/drawable/primitive_rectangle.py index 4b0d6e1..4d03f42 100644 --- a/lglpy/timeline/drawable/primitive_rectangle.py +++ b/lglpy/timeline/drawable/primitive_rectangle.py @@ -34,17 +34,17 @@ class PrimitiveRectangle: store min and max, as well as the width and height. Attributes: - min_x: minimum X coordinate. - min_y: minimum Y coordinate. - max_x: maximum X coordinate. - max_y: maximum Y coordinate. + min_x: minimum X coordinate (inclusive). + min_y: minimum Y coordinate (inclusive). + max_x: maximum X coordinate (inclusive). + max_y: maximum Y coordinate (inclusive). w: width of the primitive. h: height of the primitive. ''' __slots__ = ('min_x', 'max_x', 'min_y', 'max_y', 'w', 'h') - def __init__(self, pos, dim): + def __init__(self, pos: list[int], dim: list[int]): ''' Create a new primitive rectangle. @@ -61,7 +61,7 @@ def __init__(self, pos, dim): self.w = float(dim[0]) self.h = float(dim[1]) - def extend_rect(self, rect): + def extend_rect(self, rect) -> None: ''' Enlarge the size of this rectangle to enclose the passed rectangle. @@ -77,7 +77,7 @@ def extend_rect(self, rect): self.w = self.max_x - self.min_x self.h = self.max_y - self.min_y - def intersects(self, other): + def intersects(self, other) -> bool: ''' Test whether this rectangle intersects another. @@ -92,12 +92,12 @@ def intersects(self, other): (other.min_y <= self.max_y) and \ (other.max_y >= self.min_y) - def is_enclosed_by(self, other): + def is_enclosed_by(self, other) -> bool: ''' Test whether this rectangle is totally inside another. Args: - other: other rectangle to test. + other: the other rectangle to test. Returns: True if totally enclosed, False otherwise. @@ -107,7 +107,7 @@ def is_enclosed_by(self, other): (other.min_y <= self.min_y) and \ (other.max_y >= self.max_y) - def is_hit_by(self, x, y): + def is_hit_by(self, x: int, y: int) -> bool: ''' Test whether the specified point is inside this rectangle. @@ -121,7 +121,7 @@ def is_hit_by(self, x, y): return (self.min_x <= x <= self.max_x) and \ (self.min_y <= y <= self.max_y) - def __str__(self): + def __str__(self) -> str: ''' Return a debug string representation of this primitive box. diff --git a/lglpy/timeline/drawable/style.py b/lglpy/timeline/drawable/style.py index 861ec73..1a0ebfb 100644 --- a/lglpy/timeline/drawable/style.py +++ b/lglpy/timeline/drawable/style.py @@ -51,8 +51,10 @@ up by name. Libraries are read-only and designed to be shared by multiple user interface components. ''' +from typing import Optional -from lglpy.timeline.drawable.drawable import FONT +from .css import CSSDash +from .drawable import FONT class Style: @@ -76,7 +78,7 @@ class Style: 'fill_color', 'line_color', 'line_width', 'line_dash' ) - def __init__(self, variant=None): + def __init__(self, variant: Optional[str] = None): ''' Create a new style instance, assigning defaults if needed. @@ -102,7 +104,7 @@ def __init__(self, variant=None): self.line_color = None self.line_width = 1.0 - self.line_dash = [] + self.line_dash = CSSDash('none') @classmethod def css_factory(cls, css_style, variant=None): diff --git a/lglpy/timeline/drawable/timeline_base.py b/lglpy/timeline/drawable/timeline_base.py index acf4194..264b491 100644 --- a/lglpy/timeline/drawable/timeline_base.py +++ b/lglpy/timeline/drawable/timeline_base.py @@ -47,8 +47,8 @@ class TimelineWidgetBase: transformed from world-space to canvas-space for rendering. This widget is designed as a set of horizontal time-tracks plotted on the - canvas, with some fixed-function control regions for making actions and - setting navigation bookmarks running along the top of the widget. + canvas, with some fixed-function control regions for control actions + running along the top of the widget. The underlying viewport behavior is generic, but the zoom and pan controls (by virtue of the viewport used) only modify the X axis. Using the mouse @@ -61,7 +61,6 @@ class TimelineWidgetBase: Attributes: CLAMP_PIXELS: Width of the boundary clamp boxes, in pixels. ACTION_BAR_PIXELS: Height of the action bar region in pixels. - BOOKMARK_BAR_PIXELS: Height of the action bar region in pixels. MAX_PIXELS_PER_US_MAX : Max number of pixels per nanosecond. Increase this to allow more zoomed in views. ZOOM_SCALE: Scaling rate per step of zoom on the mouse wheel. @@ -80,9 +79,8 @@ class TimelineWidgetBase: The current set of saved active objects. ''' CLAMP_PIXELS = 12 - ACTION_BAR_PIXELS = 10 + ACTION_BAR_PIXELS = 20 BOTTOM_PAD_PIXELS = 20 - BOOKMARK_BAR_PIXELS = 15 MAX_PIXELS_PER_US = 0.15 ZOOM_RATE = 0.2 @@ -144,39 +142,12 @@ def __init__(self, trace, cs_pos, cs_dim, css, prefix): name = f'{prefix}activitybar' self.activity_bar_style = Style.css_factory(css[name]) - name = f'{prefix}bookmarkbar' - self.bookmark_bar_style = Style.css_factory(css[name]) - name = f'{prefix}activeregion' self.activity_region_style = Style.css_factory(css[name]) name = f'{prefix}limitclamp' self.clamp_style = Style.css_factory(css[name]) - # Legend configuration - self.show_legend = False - self.legend_entries = [] - - name = f'{prefix}legend' - self.legend_style = Style.css_factory(css[name]) - - # Bookmarks - self.bookmarks = {} - - name = f'{prefix}bookmark' - self.bookmark_style = Style.css_factory(css[name]) - - def add_legend_entry(self, name, style): - ''' - Add a legend entry. - - Args: - name: name of the legend entry. - style: visual style for rendering. - ''' - label = CanvasDrawableLabel(self.legend_style, name) - self.legend_entries.append((label, style)) - def update_cs(self, cs_pos, cs_dim): ''' Update the canvas-space coverage of this widget. @@ -352,27 +323,15 @@ def set_label_visibility(self, labels): ''' self.show_labels = labels - def set_legend_visibility(self, legend): - ''' - Toggle whether this widget shows the legend or not. - - Args: - legend: True if legend should be drawn. - ''' - self.show_legend = legend - - def on_mouse_scroll(self, scroll, x, y): + def on_mouse_scroll(self, scroll: str, x: int, y: int): ''' Handle a mouse scroll event. Args: - scroll: str - String indicating direction of the scroll. Must be "up" or + scroll: String indicating direction of the scroll. Must be "up" or "down"; side scrolling mice are not supported! - x: int - X coordinate of the mouse pointer in canvas-space. - y: int - Y coordinate of the mouse pointer in canvas-space. + x: X coordinate of the mouse pointer in canvas-space. + y: Y coordinate of the mouse pointer in canvas-space. Returns: Returns True if this function triggered some behavior which needs @@ -386,7 +345,7 @@ def on_mouse_scroll(self, scroll, x, y): return None # Convert the X coordinate to relative to canvas offset - x = float(x - cs.min_x) + xf = float(x - cs.min_x) # Mouse down zooms out, so we scale up the amount of WS on screen if scroll == 'down': @@ -396,7 +355,7 @@ def on_mouse_scroll(self, scroll, x, y): # Calculate the ratio of scale to apply to X.min and X.max bounds # The aim is to keep the diagram under the mouse pointer stationary - ratio_left = x / float(cs.w) + ratio_left = xf / float(cs.w) ratio_right = 1.0 - ratio_left # Calculate the change in the world visibility based on the up or down @@ -521,7 +480,7 @@ def on_mouse_drag_release(self, button, drag): end_x, end_y = drag.end # Skip drags in active region - if start_y < self.ACTION_BAR_PIXELS + self.BOOKMARK_BAR_PIXELS: + if start_y < self.ACTION_BAR_PIXELS: return False # Convert canvas-space to world-space coordinates @@ -590,17 +549,6 @@ def on_mouse_single_click(self, button, x, y, mod): self.set_clip_region(button, ws_x) return True - # If in the top 20 pixels process as a view highlight ... - bar_start = self.ACTION_BAR_PIXELS - bar_end = self.ACTION_BAR_PIXELS + self.BOOKMARK_BAR_PIXELS - if bar_start <= cs_rel_y < bar_end: - ws_x = self.vp.transform_cs_to_ws_x(x) - if (button == 'left') and (mod == ''): - self.set_bookmark(ws_x) - elif (button == 'right') and (mod == ''): - self.clear_bookmark(x) - return True - # Else process as a click event on an object ... vp = self.vp ws_x, ws_y = vp.transform_cs_to_ws(x, y) @@ -686,56 +634,6 @@ def set_active_region(self, button, ws_x): return True - def set_bookmark(self, ws_x): - ''' - Set the a new bookmark at the world-space X coordinate. - - Args: - ws_x: float - The world-space location of the click. - ''' - bookmark = get_entry_dialog(self.parent.window, 'Enter Bookmark') - if None is not bookmark: - bookmark = bookmark.strip() - - if not bookmark: - print('Warning: Bookmark not specified in dialog') - return - - # TODO: We probably want to avoid bookmarks which are too close to - # other bookmarks here, so make this an abs(diff) > limit check - if ws_x in self.bookmarks: - print('Warning: Bookmark already specified at this location') - return - - if bookmark in self.bookmarks.values(): - print(f'Warning: Bookmark "{bookmark}" already specified') - return - - self.bookmarks[ws_x] = bookmark - - return - - def clear_bookmark(self, cs_x): - ''' - Clear the a new bookmark at the canvas-space X coordinate. - - Args: - cs_x: float - The canvas-space location of the click. - ''' - # Get the bounds of a 4 pixel widget in world-space - ws_min_x = self.vp.transform_cs_to_ws_x(cs_x - 2) - ws_max_x = self.vp.transform_cs_to_ws_x(cs_x + 2) - - # Remove any bookmarks in this range - for ws in self.bookmarks: - if ws_min_x <= ws < ws_max_x: - del self.bookmarks[ws] - return True - - return False - def get_coord_str(self, cx, cy): ''' Return a coordinate string for the given canvas-space coordinate. @@ -757,22 +655,6 @@ def get_coord_str(self, cx, cy): if start <= cy < self.ACTION_BAR_PIXELS: return '' - # If in the BOOKMARK BAR then only a string if hovering over a bookmark - start = end - end += self.BOOKMARK_BAR_PIXELS - if start <= cy < end: - for bookmark, value in self.bookmarks.items(): - bx = self.vp.transform_ws_to_cs_x(bookmark) - start = bx - 3 - end = bx + 3 - if start < cx < end: - ms = bookmark / 1000000.0 - label = f'{ms:0.2f} ms, Bookmark "{value}"' - return label - - # Clear the string if not over a bookmark - return '' - # Otherwise we are over the main timeline so provide timeline coords wx = self.vp.transform_cs_to_ws_x(cx) @@ -793,7 +675,6 @@ def set_draw_clip(self, gc): ws = self.vp.ws extra_h = self.ACTION_BAR_PIXELS \ - + self.BOOKMARK_BAR_PIXELS \ + self.BOTTOM_PAD_PIXELS # Draw min_x clamp limits and mask off once drawn @@ -872,43 +753,6 @@ def draw_active_bar(self, gc): (cs_w, cs.h), style) active.draw(gc) - def draw_bookmark_bar(self, gc): - ''' - Render the bookmark control bar. - - Args: - gc: Cairo graphics context. - ''' - cs = self.vp.cs - - # Draw active region interactable zone user hint - style = self.bookmark_bar_style - min_y = cs.min_y + self.ACTION_BAR_PIXELS - height = self.BOOKMARK_BAR_PIXELS - active = CanvasDrawableRectFill((cs.min_x, min_y), - (cs.w, height), style) - active.draw(gc) - - line = Drawable.rt05(min_y + height - 1) - points = [(cs.min_x, line), (cs.max_x, line)] - active = CanvasDrawableLine(points, style) - active.draw(gc) - - # Draw bookmark points - style = self.bookmark_style - min_y = min_y + 2 - 0.5 - height = height - 4 - width = 4 - for ws_x, _ in self.bookmarks.items(): - # Skip bookmarks out of the current viewport - if not self.vp.ws.min_x < ws_x < self.vp.ws.max_x: - continue - - # Render bookmarks inside the current viewport - min_x = self.vp.transform_ws_to_cs_x(ws_x, 1) - 2 - active = CanvasDrawableRect((min_x, min_y), (width, height), style) - active.draw(gc) - def draw_active_drag(self, gc): ''' Render the active region control bar, and the active region if needed. @@ -918,7 +762,7 @@ def draw_active_drag(self, gc): ''' if self.left_drag_start: # Don't draw the highlight for an active bar drag - height = self.ACTION_BAR_PIXELS + self.BOOKMARK_BAR_PIXELS + height = self.ACTION_BAR_PIXELS if self.left_drag_start[1] < height: return @@ -935,49 +779,6 @@ def draw_active_drag(self, gc): active = CanvasDrawableRect((min_x, min_y), (w, h), style) active.draw(gc) - def draw_legend(self, gc): - ''' - Render the legend. - - Args: - gc: Cairo graphics context. - ''' - if (not self.legend_entries) or (not self.show_legend): - return - - borderpad = 10 - textpad = 5 - entrypad = 20 - cell = 10 - max_x = self.vp.cs.max_x - 10 - - width = borderpad - for label, _ in self.legend_entries: - width += label.get_label_extents(gc)[0] - width += textpad + cell + entrypad - width -= entrypad - width += borderpad - - # Draw legend border - style = self.legend_style - min_x = max_x - width + 0.5 - min_y = borderpad + 0.5 - height = borderpad + cell + borderpad - legend = CanvasDrawableRect((min_x, min_y), (width, height), style) - legend.draw(gc) - - # Draw entries - min_x += borderpad - min_y += borderpad - label_y = min_y + cell - 2 - for label, style in self.legend_entries: - legend = CanvasDrawableRect((min_x, min_y), (cell, cell), style) - legend.draw(gc) - min_x += cell + textpad - label.draw(gc, min_x, label_y) - min_x += label.get_label_extents(gc)[0] - min_x += entrypad - def draw(self, gc, ch_filter=None, ws_range=None): ''' Draw this widget. @@ -992,7 +793,6 @@ def draw(self, gc, ch_filter=None, ws_range=None): ''' cs = self.vp.cs extra_h = self.ACTION_BAR_PIXELS \ - + self.BOOKMARK_BAR_PIXELS \ + self.BOTTOM_PAD_PIXELS gc.rectangle(cs.min_x, cs.min_y, cs.w, cs.h + extra_h) @@ -1013,7 +813,6 @@ def draw(self, gc, ch_filter=None, ws_range=None): self.vp.enable_coverage_culling(False) self.set_draw_clip(gc) - self.draw_bookmark_bar(gc) self.draw_active_bar(gc) # Draw the trace @@ -1021,4 +820,3 @@ def draw(self, gc, ch_filter=None, ws_range=None): self.drawable_trace.draw(gc, vp, ch_filter, ws_range, self.show_labels) self.draw_active_drag(gc) - self.draw_legend(gc) diff --git a/lglpy/timeline/drawable/timeline_viewport.py b/lglpy/timeline/drawable/timeline_viewport.py index f10d831..f1e17d3 100644 --- a/lglpy/timeline/drawable/timeline_viewport.py +++ b/lglpy/timeline/drawable/timeline_viewport.py @@ -144,7 +144,7 @@ def update_coverage_culling(self, last_cs_x): return # Save this to avoid overlap in canvas-space - self.coverage_cull_next_cs_x = last_cs_x + 1 + self.coverage_cull_next_cs_x = last_cs_x # Save this to allow fast culling cs_delta_x = last_cs_x - self.cs.min_x + 1 diff --git a/lglpy/timeline/drawable/world_drawable.py b/lglpy/timeline/drawable/world_drawable.py index c0c5f61..7a41502 100644 --- a/lglpy/timeline/drawable/world_drawable.py +++ b/lglpy/timeline/drawable/world_drawable.py @@ -27,6 +27,8 @@ canvas-space using a viewport before they are rendered. ''' +from typing import Any + from lglpy.timeline.drawable.drawable import DrawableLabel from lglpy.timeline.drawable.primitive_rectangle import PrimitiveRectangle @@ -84,6 +86,14 @@ def __init__(self, pos, dim, style, label, label_short): if label_short: self.label_short = DrawableLabel(style, label_short) + self.user_data = None + + def set_user_data(self, user_data: Any) -> None: + ''' + Set the user data to point at an arbitrary payload. + ''' + self.user_data = user_data + def draw(self, gc, vp): ''' Render this object. diff --git a/lglpy/timeline/gui/resources/dark.css b/lglpy/timeline/gui/resources/dark.css index 9c17e20..7a7804a 100644 --- a/lglpy/timeline/gui/resources/dark.css +++ b/lglpy/timeline/gui/resources/dark.css @@ -31,16 +31,6 @@ mtv-core { line-color: #505050; } -[tlv-tlw-core] tlv-tlw-bookmarkbar { - fill-color: #2d2d2d; - line-color: #505050; -} - -[tlv-tlw-bookmarkbar] tlv-tlw-bookmark { - fill-color: #880000; - line-color: #404040; -} - [tlv-tlw-core] tlv-tlw-activeregion { fill-color: #303d3080; line-color: none; @@ -58,13 +48,6 @@ mtv-core { line-color: #ffffff; } -[tlv-tlw-core] tlv-tlw-legend { - fill-color: #262626; - line-width: 1.0; - line-color: #606060; - font-color: #808080; -} - [tlv-core] tlv-info { font-color: #b0b0b0; } diff --git a/lglpy/timeline/gui/resources/light.css b/lglpy/timeline/gui/resources/light.css index 56c1c9f..63f64e5 100644 --- a/lglpy/timeline/gui/resources/light.css +++ b/lglpy/timeline/gui/resources/light.css @@ -31,16 +31,6 @@ mtv-core { line-color: #505050; } -[tlv-tlw-core] tlv-tlw-bookmarkbar { - fill-color: #d0d0d0; - line-color: #505050; -} - -[tlv-tlw-bookmarkbar] tlv-tlw-bookmark { - fill-color: #ff7728; - line-color: #505050; -} - [tlv-tlw-core] tlv-tlw-activeregion { fill-color: #ffffff80; line-color: none; @@ -58,13 +48,6 @@ mtv-core { line-color: #202020; } -[tlv-tlw-core] tlv-tlw-legend { - fill-color: #e5e5e5; - line-width: 1.0; - line-color: #606060; - font-color: #404040; -} - [tlv-core] tlv-info { font-color: #404040; } diff --git a/lglpy/timeline/gui/timeline/info_widget.py b/lglpy/timeline/gui/timeline/info_widget.py index 126696e..05e543d 100644 --- a/lglpy/timeline/gui/timeline/info_widget.py +++ b/lglpy/timeline/gui/timeline/info_widget.py @@ -26,8 +26,10 @@ timeline visualization. ''' -from lglpy.timeline.drawable.text_pane_widget import TextPaneWidget -from lglpy.timeline.drawable.world_drawable import WorldDrawableLine +from collections import defaultdict + +from ...data.raw_trace import GPUStreamID, GPUStageID +from ...drawable.text_pane_widget import TextPaneWidget class TimelineInfoWidget(TextPaneWidget): @@ -36,6 +38,7 @@ class TimelineInfoWidget(TextPaneWidget): time ranges in the main timeline. ''' + MAX_EVENTS = 5 VALIDSORTS = ['flush', 'runtime'] def __init__(self, timeline_widget, style): @@ -46,9 +49,16 @@ def __init__(self, timeline_widget, style): ''' super().__init__((0, 0), (1, 1), style, '') self.timeline_widget = timeline_widget - self.sort_type = self. VALIDSORTS[0] + self.sort_type = self.VALIDSORTS[0] + + # Initialize the text report caches + self.cached_active_range = None + self.cached_range_info = self.compute_active_region_stats(None) + + self.cached_active_event = None + self.cached_event_info = None - def get_frame_rate(self, start, end): + def get_frame_rate_report(self, start, end): ''' Compute the frame rate for frames in the selected time range. @@ -59,138 +69,302 @@ def get_frame_rate(self, start, end): Returns: Compute frame rate or None if could not be determined. ''' - tl = self.timeline_widget - try: - channel = tl.drawable_trace.get_channel('sw.frame') - except KeyError: - return None - - frame_count = 0 - first_frame = None - last_frame = None - - def event_filter(x): - return isinstance(x, WorldDrawableLine) - - for drawable in channel.each_object(event_filter): - frame_time = drawable.ws.min_x - if start <= frame_time < end: - frame_count += 1 - if not first_frame: - first_frame = frame_time - last_frame = frame_time + # Determine which frames are entirely in the active range + out_frames = set() + in_frames = dict() + + for event in self.timeline_widget.drawable_trace.each_object(): + frame = event.user_data.frame + + # Event is not entirely in active range + # - Work starts and/or ends before time range + # End before implies start before, so don't need to check it + # - Work starts and/or ends after time range + # Start after implies end after, so don't need to check it + if event.ws.min_x < start or event.ws.max_x > end: + out_frames.add(frame) + continue - # We need at least two frame markers to bracket an FPS metric - if frame_count < 2: + # Event is entirely inside active time range + if frame not in in_frames: + in_frames[frame] = [event.ws.min_x, event.ws.max_x] + else: + in_frames[frame][0] = min(in_frames[frame][0], event.ws.min_x) + in_frames[frame][1] = max(in_frames[frame][1], event.ws.max_x) + + # Remove partial frames from the in_frames data + keys = list(in_frames.keys()) + for key in keys: + if key in out_frames: + del in_frames[key] + + # No frames found + if not in_frames: return None - frame_count = float(frame_count - 1) - duration = float(last_frame - first_frame) / 1000000000.0 - msf = (duration * 1000.0) / frame_count - fps = frame_count / duration + # Determine active frame min/max times + min_time = min(in_frames.values(), key=lambda x: x[0])[0] + max_time = max(in_frames.values(), key=lambda x: x[1])[1] + frame_count = len(in_frames) + + frame_countf = float(frame_count) + seconds = float(max_time - min_time) / 1000000000.0 + msf = (seconds * 1000.0) / frame_countf + fps = frame_countf / seconds lines = [ - f' Frames: {frame_count}' - f' Performance: {msf:0.2f} ms/F ({fps:0.2f} FPS)' + f' Frames: {int(frame_count)}', + f' Performance: {msf:0.2f} ms/frame ({fps:0.2f} FPS)' ] return lines - def get_gpu_utilization( - self, start, end, slot=('Non-fragment', 'Fragment', 'Transfer')): + def get_utilization(self, start: int, end: int, slot: list[str]) -> float: ''' Compute the hardware utilization over the active time range. + For analysis using multiple slots, time is considered active if any + slot is active. + Args: start: start of time range. end: end of time range. - slot: the hardware queues to include (default all of them). + slot: the hardware queues to analyze. Returns: - Compute frame rate or None if could not be determined. + The computed utilization percentage. ''' usage = 0 - range_end = 0 + cursor = start - def event_filter(x): + def ch_filter(x): return x.name in slot trace = self.timeline_widget.drawable_trace trace = [(x.ws.min_x, x.ws.max_x) - for x in trace.each_object(event_filter)] + for x in trace.each_object(ch_filter)] + trace.sort() for min_x, max_x in trace: - # Skip drawables which are out of the range - if max_x < start: + # Skip drawables which do not intersect range + if (max_x < cursor) or (min_x > end): continue - if min_x > end: - break - - # Clamp to the start range - min_x = max(start, min_x) + # Trim end to fit active range max_x = min(end, max_x) - # Cut off the parts which we have already counted - if range_end: - min_x = max(range_end, min_x) - max_x = max(range_end, max_x) + # Trim start to exclude range already counted for earlier events + min_x = max(cursor, min_x) # Now just store the new data ... usage += max_x - min_x - range_end = max(range_end, max_x) + cursor = max_x util = (float(usage) / float(end - start)) * 100.0 - return f'{util:0.1f}%' + return util - def get_active_region_stats(self): + def get_utilization_report(self, start, end): + ''' + Compute the hardware utilization over the active time range. + + Args: + start: start of time range. + end: end of time range. + slot: the hardware queues to include (default all of them). + + Returns: + Compute frame rate or None if could not be determined. + ''' + trace = self.timeline_widget.drawable_trace + + def ch_filt(x): + return len(x) + + channels = [x.name for x in trace.each_channel(ch_filt)] + label_len = max(len(x) for x in channels) + len(' stream:') + + metrics = [''] + metrics.append('Utilization:') + for channel in channels: + util = self.get_utilization(start, end, [channel,]) + if util == 0.0: + continue + + label = f'{channel} stream:' + metrics.append(f' {label:{label_len}} {util:>5.1f}%') + + util = self.get_utilization(start, end, channels) + label = f'Any stream:' + metrics.append(f' {label:{label_len}} {util:>5.1f}%') + metrics.append('') + return metrics + + def compute_active_region_stats(self, active): ''' Compute all metrics for the active time range. Returns: List of lines to be printed. ''' - active = self.timeline_widget.get_active_time_range(True) - if not active: - return ['Active Region: -', ''] - - duration = active[1] - active[0] - if duration < 0: - return ['Active Region: -', ''] + if not active or (active[1] - active[0]) <= 0: + return ['Active region: -', ''] - # Convert to milliseconds - duration = float(duration) / 1000000.0 - start = float(active[0]) / 1000000.0 + # Convert start to seconds and duration to milliseconds + start = float(active[0]) / 1000000000.0 + duration = float(active[1] - active[0]) / 1000000.0 lines = [ - 'Active Region:', - f' Start = {start:0.3f} ms' - f' Duration = {duration:0.3f} ms' + 'Active region:', + f' Start: {start:0.3f} s', + f' Duration: {duration:0.3f} ms' ] - fps = self.get_frame_rate(*active) - if fps: - lines.extend(fps) - - nf_util = self.get_gpu_utilization(*active, slot=("Non-fragment",)) - f_util = self.get_gpu_utilization(*active, slot=("Fragment",)) - t_util = self.get_gpu_utilization(*active, slot=("Transfer",)) - gpu_util = self.get_gpu_utilization(*active) - - util = [ - '', - 'Utilization:', - f' Non-fragment: {nf_util}', - f' Fragment: {f_util}', - f' Transfer: {t_util}', - f' GPU: {gpu_util}', - '' - ] + if fps_report := self.get_frame_rate_report(*active): + lines.extend(fps_report) + + if util_report := self.get_utilization_report(*active): + lines.extend(util_report) - lines.extend(util) return lines + def get_active_region_stats(self): + ''' + Get the metrics for the active time range. + + This function uses a cached lookup to avoid re-calculating every + redraw, as the stats computation can be quite slow. + + Returns: + List of lines to be printed. + ''' + active = self.timeline_widget.get_active_time_range(True) + + if self.cached_active_range != active: + self.cached_active_range = active + self.cached_range_info = self.compute_active_region_stats(active) + + return self.cached_range_info + + def compute_active_event_stats_multi(self, active): + ''' + Get the metrics for the active time range. + + This function uses a cached lookup to avoid re-calculating every + redraw, as the stats computation can be quite slow. + + Returns: + List of lines to be printed. + ''' + active.sort(key=lambda x: x.start_time) + + # Per-stream time for a given submitted workload + tag_stream_time = {} + # Per-tag event mapping (keeps an arbitrary one) + tag_event = {} + # Per-work time for a submitted workload + total_tag_time = defaultdict(int) + # Per-steam time for all workloads + total_stream_time = defaultdict(int) + + max_name_len = 0 + + for event in active: + total_stream_time[event.stream] += event.duration + total_tag_time[event.tag_id] += event.duration + + name_len = len(GPUStreamID.get_ui_name(event.stream)) + max_name_len = max(max_name_len, name_len) + + if event.tag_id not in tag_stream_time: + tag_stream_time[event.tag_id] = defaultdict(int) + tag_event[event.tag_id] = event + + tag_stream_time[event.tag_id][event.stream] += event.duration + + metrics = [''] + # Report total runtime of the selected workloads + other_names = [ + 'API workloads:', + 'Hardware workloads:' + ] + + metrics.append('Active workload runtime:') + + label_len = max_name_len + len(' stream:') + label_len = max(max(len(x) for x in other_names), label_len) + + label = other_names[0] + value = len(tag_event) + metrics.append(f' {label:{label_len}} {value:>5}') + + label = other_names[1] + value = len(active) + metrics.append(f' {label:{label_len}} {value:>5}') + + active_streams = sorted(total_stream_time.keys()) + for stream in active_streams: + label = f'{GPUStreamID.get_ui_name(stream)} stream:' + duration = float(total_stream_time[stream]) / 1000000.0 + metrics.append(f' {label:{label_len}} {duration:>5.2f} ms') + + # Report total N workloads + metrics.append('') + top_n_limit = min(self.MAX_EVENTS, len(total_tag_time)) + if top_n_limit > 1: + metrics.append(f'Top {top_n_limit} workload runtimes:') + else: + metrics.append(f'Workload runtime:') + + tags_by_cost = sorted( + total_tag_time, key=total_tag_time.get, reverse=True) + + for n_count, tag_id in enumerate(tags_by_cost): + if n_count >= top_n_limit: + break + + event = tag_event[tag_id] + costs = tag_stream_time[tag_id] + + # Report total N workloads + label = event.get_workload_name() + metrics.append(f' {label}') + + active_streams = sorted(costs.keys()) + label_len = max_name_len + len(' stream:') + for stream in active_streams: + label = f'{GPUStreamID.get_ui_name(stream)} stream:' + duration = float(costs[stream]) / 1000000.0 + metrics.append(f' {label:{label_len}} {duration:>5.2f} ms') + + metrics.append('') + return metrics + + def get_active_event_stats(self): + ''' + Get the metrics for the active event selection. + + This function uses a cached lookup to avoid re-calculating every + redraw, as the stats computation can be quite slow. + + Returns: + List of lines to be printed. + ''' + active = self.timeline_widget.get_active_objects(True) + active.sort(key=lambda x: x.start_time) + + if self.cached_active_event == active: + return self.cached_event_info + + elif len(active) == 0: + info = None + + else: + info = self.compute_active_event_stats_multi(active) + + self.cached_event_info = info + return self.cached_event_info + def draw(self, gc): ''' Draw this widget. @@ -200,14 +374,13 @@ def draw(self, gc): ''' lines = [] - # Top line: Active region size (optional) + # Top line: Active region statistics message = self.get_active_region_stats() lines.extend(message) - active_objects = self.timeline_widget.get_active_objects(True) - # If we have one object just print it out - if 1 == len(active_objects): - lines.extend(active_objects[0].getDescription()) + # Bottom line: Active object statistics + if message := self.get_active_event_stats(): + lines.extend(message) self.set_text('\n'.join(lines)) super().draw(gc) diff --git a/lglpy/timeline/gui/timeline/timeline_widget.py b/lglpy/timeline/gui/timeline/timeline_widget.py index ba1b784..55c2335 100644 --- a/lglpy/timeline/gui/timeline/timeline_widget.py +++ b/lglpy/timeline/gui/timeline/timeline_widget.py @@ -56,8 +56,7 @@ def on_mouse_single_click(self, button, x, y, mod): We follow the same convention as the `View` module API, so see that class for documentation. ''' - parent = super() - if parent.on_mouse_single_click(button, x, y, mod): + if super().on_mouse_single_click(button, x, y, mod): return True # The only extra options are right menu click options @@ -65,8 +64,7 @@ class for documentation. return False # Try to find a clicked object, if there is one - vp = self.vp - ws_x, ws_y = vp.transform_cs_to_ws(x, y) + ws_x, ws_y = self.vp.transform_cs_to_ws(x, y) clicked = self.drawable_trace.get_clicked_object(ws_x, ws_y) @@ -77,12 +75,14 @@ class for documentation. if clicked: menu = Gtk.Menu() - menui = Gtk.MenuItem('Highlight by Render Pass') - menui.connect_object('activate', self.on_orc1, clicked) + menui = Gtk.MenuItem('Select by frame') + menui.connect_object( + 'activate', self.on_select_events_by_frame, clicked) menu.append(menui) - menui = Gtk.MenuItem('Highlight by Frame') - # TODO: Implement this + menui = Gtk.MenuItem('Select by workload') + menui.connect_object( + 'activate', self.on_select_events_by_workload, clicked) menu.append(menui) menu.show_all() @@ -94,38 +94,21 @@ class for documentation. if mod == '': menu = Gtk.Menu() - menui = Gtk.MenuItem('Clear Selected Time Range') - menui.connect_object('activate', self.on_norc1, clicked) + menui = Gtk.MenuItem('Clear active time range') + menui.connect_object( + 'activate', self.on_clear_time_range, clicked) menu.append(menui) - menui = Gtk.MenuItem('Clear Selected Objects') - menui.connect_object('activate', self.on_norc2, clicked) + menui = Gtk.MenuItem('Clear active events') + menui.connect_object( + 'activate', self.on_deselect_events, clicked) menu.append(menui) - menui = Gtk.MenuItem('Clear Timeline Clamps') - menui.connect_object('activate', self.on_norc3, clicked) + menui = Gtk.MenuItem('Clear timeline clamps') + menui.connect_object( + 'activate', self.on_clear_timeline_clamps, clicked) menu.append(menui) - bookmarks = {} - for time, name in self.bookmarks.items(): - # Keep bookmarks in active clamp range only - if self.ws_clamp_min_x < time < self.ws_clamp_max_x: - bookmarks[time] = name - - if bookmarks: - menui = Gtk.MenuItem('Jump to Bookmark') - menu.append(menui) - - submenu = Gtk.Menu() - menui.set_submenu(submenu) - - handler = self.on_jump_bookmark - for bookmark in sorted(bookmarks.keys()): - key = bookmarks[bookmark] - menui = Gtk.MenuItem(key) - menui.connect_object('activate', handler, key) - submenu.append(menui) - menu.show_all() menu.popup_at_pointer(event) @@ -133,23 +116,43 @@ class for documentation. return False - def on_orc1(self, clicked_object): + def on_select_events_by_frame(self, clicked_object): ''' - Right click menu handler -> highlight by single render pass + Right click menu handler -> highlight by single frame. ''' + # Do nothing if the event user data doesn't have frame metadata + if clicked_object.user_data.frame is None: + return + self.clear_active_objects() - self.add_to_active_objects(clicked_object) + + def filt(x): + return x.user_data.frame == clicked_object.user_data.frame + + to_activate = [] + for drawable in self.drawable_trace.each_object(obj_filter=filt): + to_activate.append(drawable) + + self.add_multiple_to_active_objects(to_activate) self.parent.queue_draw() - def on_norc1(self, clicked_object): + def on_select_events_by_workload(self, clicked_object): ''' - Right click menu handler -> clear range + Right click menu handler -> highlight by single workload. ''' - del clicked_object - self.active_time_range = [] + self.clear_active_objects() + + def filt(x): + return x.user_data.tag_id == clicked_object.user_data.tag_id + + to_activate = [] + for drawable in self.drawable_trace.each_object(obj_filter=filt): + to_activate.append(drawable) + + self.add_multiple_to_active_objects(to_activate) self.parent.queue_draw() - def on_norc2(self, clicked_object): + def on_deselect_events(self, clicked_object): ''' Right click menu handler -> clear selection ''' @@ -157,7 +160,15 @@ def on_norc2(self, clicked_object): self.clear_active_objects() self.parent.queue_draw() - def on_norc3(self, clicked_object): + def on_clear_time_range(self, clicked_object): + ''' + Right click menu handler -> clear range + ''' + del clicked_object + self.active_time_range = [] + self.parent.queue_draw() + + def on_clear_timeline_clamps(self, clicked_object): ''' Right click menu handler -> clear clamp limits ''' @@ -167,85 +178,3 @@ def on_norc3(self, clicked_object): self.ws_clamp_max_x = self.original_trace_ws_max_x + 100 self.trace_ws_max_x = self.ws_clamp_max_x self.parent.queue_draw() - - def on_jump_bookmark(self, name): - ''' - Right click menu handler -> jump to bookmark - ''' - for ws_target_x, value in self.bookmarks.items(): - if value == name: - break - else: - return - - clamp_min_x = self.ws_clamp_min_x - clamp_max_x = self.ws_clamp_max_x - if not clamp_min_x <= ws_target_x <= clamp_max_x: - print(f'WARNING: Bookmark {name} outside of clamped range') - return - - # Put the bookmark in the middle of the screen, but handle clamps - # gracefully, which may pull it off center - ws_start_x = max(ws_target_x - (self.vp.ws.w / 2), clamp_min_x) - ws_end_x = min(ws_target_x + (self.vp.ws.w / 2), clamp_max_x) - - # Finally we can update the viewport and render - ws = self.vp.ws - ws_pos_new = [ws_start_x, ws.min_y] - ws_dim_new = [ws_end_x - ws_start_x, ws.max_y - ws.min_y] - self.vp.update_ws(ws_pos_new, ws_dim_new) - - self.parent.queue_draw() - - def jump_one_frame(self, forward): - ''' - Handle jump one frame forward or backwards. - ''' - # Find the scene object in the config - def ch_filter(channel): - return channel.name in ['sw.frame'] - - def obj_filter(event): - return isinstance(event, WorldDrawableLine) - - # Find the frame either side of the first eglSwap in the viewport - prev = None - start = None - end = None - - ws = self.vp.ws - for drawable in self.drawable_trace.each_object(ch_filter, obj_filter): - if drawable.ws.min_x >= ws.min_x: - if not start: - start = drawable.ws.min_x - else: - end = drawable.ws.min_x - break - else: - prev = drawable.ws.min_x - - if None is end: - print('Warning: Unable to determine frame time') - return - - # Use gap between N and N+1 when moving forwards - if forward: - ws_pos_new = [ws.min_x + end - start, ws.min_y] - # Use gap between N-1 and N when moving backwards (if there is a - # backwards). This ensures that toggling left and right is stable - # without jitter - elif prev: - ws_pos_new = [ws.min_x - start + prev, ws.min_y] - # Otherwise nothing to do, so return - else: - return - - # Clamp the start against the clamp ranges - ws_pos_new = [max(self.ws_clamp_min_x, ws_pos_new[0]), ws_pos_new[1]] - max_w = self.ws_clamp_max_x - ws_pos_new[0] - 5 - - width = min(ws.max_x - ws.min_x, max_w) - ws_dim_new = [width, ws.max_y - ws.min_y] - - self.vp.update_ws(ws_pos_new, ws_dim_new) - self.parent.queue_draw() diff --git a/lglpy/timeline/gui/timeline/view.py b/lglpy/timeline/gui/timeline/view.py index bf03c4c..c3381e3 100644 --- a/lglpy/timeline/gui/timeline/view.py +++ b/lglpy/timeline/gui/timeline/view.py @@ -31,15 +31,14 @@ # pylint: disable=wrong-import-position from gi.repository import Gtk -from lglpy.timeline.gui.view import View -from lglpy.timeline.gui.timeline.timeline_widget import TimelineWidget -from lglpy.timeline.gui.timeline.info_widget import TimelineInfoWidget -from lglpy.timeline.drawable.drawable_trace import DrawableTrace -from lglpy.timeline.drawable.drawable_channel import DrawableChannel -from lglpy.timeline.drawable.drawable_channel import DrawableChannelFrameMarkers -from lglpy.timeline.drawable.world_drawable import WorldDrawableRect -from lglpy.timeline.drawable.style import Style, StyleSet, StyleSetLibrary +from .info_widget import TimelineInfoWidget +from .timeline_widget import TimelineWidget +from ..view import View +from ...drawable.drawable_trace import DrawableTrace +from ...drawable.drawable_channel import DrawableChannel +from ...drawable.world_drawable import WorldDrawableRect +from ...drawable.style import Style, StyleSet, StyleSetLibrary class FakeMouseDrag: @@ -80,7 +79,7 @@ class TLSpec: specMap = {} # type: dict[str, TLSpec] - def __init__(self, name, row, layer, cull, click, label, frame=False): + def __init__(self, name, row, layer, cull, click, label): ''' Create the specification of a new channel row. ''' @@ -90,11 +89,9 @@ def __init__(self, name, row, layer, cull, click, label, frame=False): self.cull = cull self.click = click self.label = label - self.frame = frame # Update the class state cache self.__class__.specMap[name] = self - self.cached_start_y = None def get_y_start(self): @@ -143,14 +140,14 @@ def get_box(cls, channel, start, end, style, label, short, user_data=None): ''' Build a drawable box to go into a channel. - # TODO: Move this to DrawableChannel? + TODO: Move this to DrawableChannel? ''' channel = cls.specMap[channel] pos = (start, channel.get_y_start()) dim = (end - start, cls.CHANNEL_BOX_Y) short = None if short == label else short draw = WorldDrawableRect(pos, dim, style, label, short) - draw.user_data = user_data + draw.set_user_data(user_data) return draw @@ -188,7 +185,7 @@ class TLStyles(StyleSetLibrary): the drawable styles which are used by the rendering of the timeline. ''' - COLORS = ["frame"] + COLORS = ['frame'] COLORS_ROTATE = ['frame', 'fbo', 'win'] @@ -274,7 +271,7 @@ class TimelineView(View): MENU_NAME = 'Timeline' DEBUG_DRAW_TIME = False MENU_REQUIRES_DATA_FILE = True - INFO_PANEL_W = 350 + INFO_PANEL_W = 450 def __init__(self, window, css): ''' @@ -291,8 +288,8 @@ def __init__(self, window, css): self.timeline_trace = None self.info_widget = None + # Spec includes lanes that collide, but only one exists at a time self.timeline_spec = ( - TLSpec('Frame', 0, 1, False, False, False, True), TLSpec('Compute', 1, 1, True, True, True), TLSpec('Non-fragment', 2, 1, True, True, True), TLSpec('Binning', 2, 1, True, True, True), @@ -302,7 +299,6 @@ def __init__(self, window, css): ) self.timeline_colors = ( - TLColor('Frame', 'frame', 'all', False), TLColor('Compute', 'win', 'window', True), TLColor('Compute', 'fbo', 'fbo', True), TLColor('Non-fragment', 'win', 'window', True), @@ -320,7 +316,6 @@ def __init__(self, window, css): self.timeline_styles = TLStyles(self.css, self.timeline_colors) self.menu_visibility_spec = ( - [1, 'Show legend', False, self.on_visibility_other, 'legend'], [1, 'Show labels', True, self.on_visibility_other, 'labels'], [1, 'Show info panel', True, self.on_visibility_other, 'info'] ) @@ -351,31 +346,6 @@ def __init__(self, window, css): item[0] = menu_item menu_item.set_active(item[2]) - menu_item = Gtk.SeparatorMenuItem() - menu_root.append(menu_item) - - menu_item = Gtk.MenuItem('Channel Visibility') - menu_root.append(menu_item) - - submenu_root = Gtk.Menu() - menu_item.set_submenu(submenu_root) - - menu_item = Gtk.SeparatorMenuItem() - menu_root.append(menu_item) - - menu_item = Gtk.MenuItem('Infopanel Options') - menu_root.append(menu_item) - - submenu_root = Gtk.Menu() - menu_item.set_submenu(submenu_root) - - menu_item = Gtk.SeparatorMenuItem() - menu_root.append(menu_item) - - menu_item = Gtk.MenuItem('Clear Bookmarks') - menu_root.append(menu_item) - menu_item.connect_object('activate', self.on_clear_bookmarks, None) - self.menu_bar.show_all() def on_visibility_other(self, item): @@ -387,20 +357,10 @@ def on_visibility_other(self, item): # Resize to force repartioning of space if panel changed ... if (item[4] == 'labels') and self.timeline_widget: self.timeline_widget.set_label_visibility(state) - if (item[4] == 'legend') and self.timeline_widget: - self.timeline_widget.set_legend_visibility(state) if item[4] == 'info': self.resize() self.parent.queue_draw() - def on_clear_bookmarks(self, _unused): - ''' - Event handler for bookmark menu actions. - ''' - if self.timeline_widget: - self.timeline_widget.bookmarks = {} - self.parent.queue_draw() - def load(self, trace_data=None): ''' Populate this view with a loaded data file. @@ -419,12 +379,8 @@ def load(self, trace_data=None): # TODO: Channel names need to be made dynamic for tl in self.timeline_spec: - if not tl.frame: - channel = DrawableChannel( - tl.name, trace, tl.layer, tl.cull, tl.click) - else: - channel = DrawableChannelFrameMarkers( - tl.name, trace, tl.layer) + channel = DrawableChannel( + tl.name, trace, tl.layer, tl.cull, tl.click) channel.label_visible = tl.label # Add scheduling channels @@ -443,7 +399,7 @@ def load(self, trace_data=None): style = self.timeline_styles.get_style(name, 0, workload) draw = TLSpec.get_box(name, stime, etime, style, - llabel, slabel, None) + llabel, slabel, event) channel.add_object(draw) # Compile the trace to improve performance @@ -456,11 +412,6 @@ def load(self, trace_data=None): labels = self.config_visibility['other']['labels'] self.timeline_widget.set_label_visibility(labels) - legend_style = self.timeline_styles.get_style('Fragment', 0, 'window') - self.timeline_widget.add_legend_entry('EGL Window', legend_style) - legend_style = self.timeline_styles.get_style('Fragment', 0, 'fbo') - self.timeline_widget.add_legend_entry('Offscreen FBO', legend_style) - style = Style.css_factory(self.css['tlv-info']) self.info_widget = TimelineInfoWidget(self.timeline_widget, style) @@ -529,17 +480,11 @@ def on_key_press(self, key, mod): drag = FakeMouseDrag(drag) return self.on_mouse_drag('middle', drag) - if (key == 'Left') and (mod == 's'): - return self.timeline_widget.jump_one_frame(False) - if (key == 'Right') and (mod == ''): drag = -self.width / 15 drag = FakeMouseDrag(drag) return self.on_mouse_drag('middle', drag) - if (key == 'Right') and (mod == 's'): - return self.timeline_widget.jump_one_frame(True) - # No valid key was detected, so don't update any rendering return False diff --git a/lglpy/timeline/gui/view.py b/lglpy/timeline/gui/view.py index e06d3b9..b2af8db 100644 --- a/lglpy/timeline/gui/view.py +++ b/lglpy/timeline/gui/view.py @@ -21,15 +21,24 @@ # SOFTWARE. # ----------------------------------------------------------------------------- ''' -TODO +This module define the base class interface for View plugins that is used by +the root Window and event system to interface with the implementation of Views. ''' +from typing import TYPE_CHECKING + +import cairo +# pylint: disable=wrong-import-position import gi gi.require_version('Gtk', '3.0') -# pylint: disable=wrong-import-position from gi.repository import Gtk -from lglpy.timeline.drawable.drawable import * +if TYPE_CHECKING: + from .window import Window +from ..drawable.drawable import * +from ..drawable.css import CSSStylesheet +from ..drawable.mouse_button import MouseDrag +from ..data.processed_trace import GPUTrace class View: @@ -43,52 +52,37 @@ class View: particular subclass, so subclasses only have to implement handlers for the events they actually want to process. - :CVariables: - MENU_NAME: str - The name to use in the Views drop-down menu. Should be replaced by - the subclass. - MENU_REQUIRES_DATA_FILE: bool - True if the menu is greyed out if there is no data file loaded. - Attributes: - window: window - The parent window surface which we use to retrieve the canvas + MENU_NAME: The name to use in the Views drop-down menu. Should be + replaced by the subclass. + MENU_REQUIRES_DATA_FILE: True if the menu is greyed out if there is no + data file loaded. + window: The parent window surface which we use to retrieve the canvas surface from when it is available. This canvas must be available via an attribute window.canvas during the first draw operation. - canvas: GTK canvas - The renderable surface we can use with Cairo rendering operations - config: ConfigSection - The configuration options loaded for this View from file. - width: int - The current width of the rendering canvas. This is automatically - updated on window resize. - height: int - The current height of the rendering canvas. This is automatically + canvas: The surface we can use with Cairo rendering operations + width: The current width of the rendering canvas. This is automatically updated on window resize. - menu_item: GTK.menu_item - The GTK menu related to this view. - css: CSSStylesheet - The CSS stylesheet instance for styling the GUI. - background_color: (float, float, float, float) - The view background color. + height: The current height of the rendering canvas. This is + automatically updated on window resize. + menu_item: The GTK menu related to this view. + css: The CSS-ish stylesheet instance for styling the GUI. + background_color:q The view background color. ''' MENU_NAME = "<>" MENU_REQUIRES_DATA_FILE = True - def __init__(self, window, css): + def __init__(self, window: 'Window', css: CSSStylesheet): ''' - Create a new `View` inside the scope of the parent window and - rendering config options. + Create a new `View` inside the scope of the parent window. Args: - window: window - The parent window surface which we use to retrieve the canvas - surface from when it is available. This canvas must be + window: The parent window surface which we use to retrieve the + canvas surface from when it is available. This canvas must be available via an attribute window.canvas during the first draw operation, but can be None if not valid at that time. - css: CSSStylesheet - The style settings to use when styling this View. + css: The CSS-ish style settings to use when styling this View. ''' self.window = window self.css = css @@ -99,57 +93,65 @@ def __init__(self, window, css): self.height = None self.menu_item = None - self.background_color = css["mtv-core"]["fill-color"] + self.background_color = css['mtv-core']['fill-color'] - def load(self, trace_data): + def load(self, trace_data: GPUTrace) -> None: ''' Load data into this view. Args: - trace_data: `MTVFileReader` - The parsed trace file. + trace_data: The parsed trace file. ''' del trace_data if self.menu_item is not None: self.menu_item.set_sensitive(True) - def unload(self): + def unload(self) -> None: ''' Load data into this view. ''' - if (self.menu_item is not None) and self.MENU_REQUIRES_DATA_FILE: + if self.menu_item is not None and self.MENU_REQUIRES_DATA_FILE: self.menu_item.set_sensitive(False) - def activate(self): + def activate(self) -> None: ''' - Turn this view into an activated view. This function does nothing in - the base class, but subclasses can use it to implement deferred - behavior which is only triggered when the view is activated, rather - than when it is initialized or data or loaded. + Turn this view into an activated view. + + This function does nothing in the base class, but subclasses can use it + to implement deferred behavior which is only triggered when the view is + activated, rather than when it is initialized or data or loaded. ''' - def deactivate(self): + def deactivate(self) -> None: ''' - Turn this view into an deactivated view. This function does nothing in - the base class, but subclasses can use it to implement behavior which - is triggered when the user switches away from this view, such as - discarding transient caches in order to save memory + Turn this view into an deactivated view. + + This function does nothing in the base class, but subclasses can use it + to implement behavior which is triggered when the user switches away + from this view, such as discarding transient caches in order to save + memory. ''' - def get_view_menu_item(self): + def get_view_menu_item(self) -> Gtk.MenuItem: ''' - Return the menu for this. This will return a dummy menu if no specific - menu is specified in the subclass. + Return the view menu for this. + + This will return a dummy menu if no menu is specified in the subclass. + + Returns: + The menubar menu item for this view. ''' if self.menu_item is None: self.menu_item = Gtk.MenuItem(self.MENU_NAME) + assert self.menu_item is not None + if self.MENU_REQUIRES_DATA_FILE: self.menu_item.set_sensitive(False) self.menu_item.show() return self.menu_item - def resize(self): + def resize(self) -> None: ''' Handle a canvas resize event if we have a window surface active. ''' @@ -158,15 +160,15 @@ def resize(self): self.width = size.width self.height = size.height - def get_cairo_context(self): + def get_cairo_context(self) -> cairo.Context: ''' Populate the necessary draw credentials of this drawing operation. Most of the GUI is statically configured, so only the Cairo rendering context needs regenerating each time. Returns: - Returns a Cairo rendering context which will remain valid for the - duration of this draw operation. + A Cairo rendering context which will remain valid for the duration + of this draw operation. ''' # If we do not have cached state then cache it if self.canvas is None: @@ -179,9 +181,12 @@ def get_cairo_context(self): self.resize() # Always return the new cairo context + assert self.canvas is not None return self.canvas.get_property('window').cairo_create() - def set_draw_clip(self, gc, draw_pos, draw_dim): + def set_draw_clip( + self, gc: cairo.Context, draw_pos: list[int], + draw_dim: list[int]) -> None: ''' Clip the given rendering context to an axis-aligned rectangular region. @@ -194,7 +199,7 @@ def set_draw_clip(self, gc, draw_pos, draw_dim): gc.rectangle(draw_pos[0], draw_pos[1], draw_dim[0], draw_dim[1]) gc.clip() - def draw_view(self, draw_pos, draw_dim): + def draw_view(self, draw_pos: list[int], draw_dim: list[int]): ''' Render this view within the given clip region. @@ -203,14 +208,15 @@ def draw_view(self, draw_pos, draw_dim): draw_dim: The draw size (width and height) Returns: - Returns a Cairo rendering context which will remain valid for the - duration of this draw operation. + A Cairo rendering context which will remain valid for the duration + of this draw operation. ''' gc = self.get_cairo_context() self.set_draw_clip(gc, draw_pos, draw_dim) # Fill the background with a clear color + assert self.width and self.height gc.rectangle(0, 0, self.width, self.height) gc.set_source_rgba(*self.background_color) gc.fill() @@ -223,161 +229,144 @@ def draw_view(self, draw_pos, draw_dim): # pylint: disable=unused-argument - def on_key_press(self, key, mod): + def on_key_press(self, key: str, mod: str) -> bool: ''' - Handle a key press event. This is a stub function, a subclass - must provide the detailed handling methods. + Handle a key press event. + + This is a stub function, a subclass must provide handling methods. Args: - key: str - The ASCII character for the key press which has been detected. + key: The ASCII character for the key press which has been detected. Note that this is canonicalized to be lower case, so modifiers which GTK normally pre-applies, like shift, are reverted before the call gets this far. - mod: str - A modifier string listing the modifiers which are applied. - Currently this may contain zero or more of "a", "c", or "s" - (in that order). + mod: A modifier string listing the modifiers which are applied. + Currently this may contain zero or more of "a"lt, "c"trl, or + "s"hift, in that order. Returns: - Returns True if this function triggered some behavior which - needs a redraw, False otherwise. + True if this function triggered some behavior which needs a redraw. ''' return False - def on_mouse_single_click(self, button, x, y, mod): + def on_mouse_single_click( + self, button: str, x: int, y: int, mod: str) -> bool: ''' - Handle a mouse single click event. This is a stub function, a subclass - must provide the detailed handling methods. + Handle a mouse single click event. + + This is a stub function, a subclass must provide handling methods. Args: - button: str - String indicating which button was clicked. Must be "left", + button: String indicating which button was clicked. Must be "left", "right", or "middle". Other mice buttons are are not supported! - x: int - X coordinate of the mouse pointer in canvas-space. - y: int - Y coordinate of the mouse pointer in canvas-space. - mod: str - A modifier string listing the modifiers which are applied. - Currently this may contain zero or more of "a", "c", or "s" - (in that order).. + x: X coordinate of the mouse pointer in canvas-space. + y: Y coordinate of the mouse pointer in canvas-space. + mod: A modifier string listing the modifiers which are applied. + Currently this may contain zero or more of "a"lt, "c"trl, or + "s"hift, in that order. Returns: - Returns True if this function triggered some behavior which - needs a redraw, False otherwise. + True if this function triggered some behavior which needs a redraw. ''' return False - def on_mouse_double_click(self, button, x, y, mod): + def on_mouse_double_click( + self, button: str, x: int, y: int, mod: str) -> bool: ''' - Handle a mouse single click event. This is a stub function, a subclass - must provide the detailed handling methods. + Handle a mouse double click event. + + This is a stub function, a subclass must provide handling methods. Args: - button: str - String indicating which button was clicked. Must be "left", + button: String indicating which button was clicked. Must be "left", "right", or "middle". Other mice buttons are are not supported! - x: int - X coordinate of the mouse pointer in canvas-space. - y: int - Y coordinate of the mouse pointer in canvas-space. - mod: str - A modifier string listing the modifiers which are applied. - Currently this may contain zero or more of "a", "c", or "s" - (in that order). + x: X coordinate of the mouse pointer in canvas-space. + y: Y coordinate of the mouse pointer in canvas-space. + mod: A modifier string listing the modifiers which are applied. + Currently this may contain zero or more of "a"lt, "c"trl, or + "s"hift, in that order. Returns: - Returns True if this function triggered some behavior which - needs a redraw, False otherwise. + True if this function triggered some behavior which needs a redraw. ''' return False - def on_mouse_drag(self, button, drag): + def on_mouse_drag(self, button: str, drag: MouseDrag) -> bool: ''' - Handle a mouse drag event. This is a stub function, a subclass - must provide the detailed handling methods. + Handle a mouse drag event. + + This is a stub function, a subclass must provide handling methods. Args: - button: str - String indicating which button was clicked. Must be "left", + button: String indicating which button was clicked. Must be "left", "right", or "middle". Other mice buttons are are not supported! - drag: MouseDrag` - The composite state of this mouse drag event. + drag: The state of this mouse drag event. Returns: - Returns True if this function triggered some behavior which needs - a redraw, False otherwise. + True if this function triggered some behavior which needs a redraw. ''' return False - def on_mouse_drag_release(self, button, drag): + def on_mouse_drag_release(self, button: str, drag: MouseDrag) -> bool: ''' - Handle a mouse drag release event. This is a stub function, a subclass - must provide the detailed handling methods. + Handle a mouse drag release event. + + This is a stub function, a subclass must provide handling methods. Args: - button: str - String indicating which button was clicked. Must be "left", + button: String indicating which button was clicked. Must be "left", "right", or "middle". Other mice buttons are are not supported! - drag: MouseDrag` - The composite state of this mouse drag event. + drag: The state of this mouse drag event. Returns: - Returns True if this function triggered some behavior which - needs a redraw, False otherwise. + True if this function triggered some behavior which needs a redraw. ''' return False - def on_mouse_move(self, x, y): + def on_mouse_move(self, x: int, y: int) -> bool: ''' - Handle a mouse move event. This is a stub function, a subclass must - provide the detailed handling methods. + Handle a mouse move event. + + This is a stub function, a subclass must provide handling methods. Args: - x: int - Mouse x coordinate in screen space. - y: int - Mouse y coordinate in screen space. + x: Mouse x coordinate in screen space. + y: Mouse y coordinate in screen space. Returns: - Returns True if this function triggered some behavior which - needs a redraw, False otherwise. + True if this function triggered some behavior which needs a redraw. ''' return False - def on_mouse_release(self, button): + def on_mouse_release(self, button: str) -> bool: ''' - Handle a mouse single click event. This is a stub function, a subclass - must provide the detailed handling methods. + Handle a mouse single click event. + + This is a stub function, a subclass must provide handling methods. Args: - button: str - String indicating which button was released. Must be "left", - "right", or "middle". Other mice buttons are are not supported! + button: String indicating which button was released. Must be + "left", "right", or "middle". Other mice buttons are are not + supported! Returns: - Returns True if this function triggered some behavior which - needs a redraw, False otherwise. + True if this function triggered some behavior which needs a redraw. ''' return False - def on_mouse_scroll(self, scroll, x, y): + def on_mouse_scroll(self, scroll: str, x: int, y: int) -> bool: ''' - Handle a mouse scroll event. This is a stub function, a subclass must - provide the detailed handling methods. + Handle a mouse scroll event. + + This is a stub function, a subclass must provide handling methods. Args: - scroll: str - String indicating direction of the scroll. Must be "up" or + scroll: String indicating direction of the scroll. Must be "up" or "down"; side scrolling mice are not supported! - x: int - X coordinate of the mouse pointer in canvas-space. - y: int - Y coordinate of the mouse pointer in canvas-space. + x: X coordinate of the mouse pointer in canvas-space. + y: Y coordinate of the mouse pointer in canvas-space. Returns: - Returns True if this function triggered some behavior which needs - a redraw, False otherwise. + True if this function triggered some behavior which needs a redraw. ''' return False diff --git a/lglpy/timeline/gui/window.py b/lglpy/timeline/gui/window.py index 88651db..6b3c9d2 100644 --- a/lglpy/timeline/gui/window.py +++ b/lglpy/timeline/gui/window.py @@ -28,6 +28,7 @@ import sys import time import traceback +from typing import Optional, Union import gi gi.require_version('Gtk', '3.0') @@ -56,7 +57,7 @@ class Window: which are each designed to provide a specific interpretation of the data. ''' - DEFAULT_APP_TITLE = 'Arm GPU Timeline Viewer' + APP_TITLE = 'Arm GPU Timeline Viewer' DEFAULT_VIEW = 'Homescreen' RAW_MOUSE_EVENT = None @@ -65,9 +66,6 @@ class Window: def get_raw_mouse_event(cls): ''' Get the raw mouse event. - - TODO: This is a nasty hack relying on Python being single threaded, and - this class being a singleton. ''' return cls.RAW_MOUSE_EVENT @@ -75,17 +73,12 @@ def get_raw_mouse_event(cls): def set_raw_mouse_event(cls, event): ''' Set the raw mouse event. - - TODO: This is a nasty hack relying on Python being single threaded, and - this class being a singleton. ''' cls.RAW_MOUSE_EVENT = event def queue_draw(self): ''' Force a global redraw. - - TODO: Move, not a global. ''' self.canvas.queue_draw() @@ -103,7 +96,7 @@ def __init__(self, style, trace_file=None, metadata_file=None): # Open a window ... self.window = Gtk.Window() - self.window.set_title(self.DEFAULT_APP_TITLE) + self.window.set_title(self.APP_TITLE) self.window.connect('destroy', Gtk.main_quit) self.window.set_size_request(1024, 500) self.window.set_default_size(1600, 500) @@ -236,45 +229,52 @@ def __init__(self, style, trace_file=None, metadata_file=None): self.window.present() Gtk.main() - def on_menu_file_open(self, menu): + def on_menu_file_open(self, menu: str) -> None: ''' Handle a File->Open selection event from the menu bar. Args: - menu: str - The menu name to load + menu: The menu name being triggered. ''' + del menu + + # Get the requested file name file_name = get_open_filechoice( self.window, 'Trace files', ('*.perfetto',)) + # User cancelled, so no file name returned if None is file_name: self.status.log('Open cancelled (user)') return + # User selected the same file so do nothing if file_name == self.loaded_file_path: self.status.log('Open skipped (same file)') return - # Deactivate all the plugins + # File change required so reset state self.view.deactivate() for view in self.loaded_views.values(): view.unload() + self.view = None self.on_menu_view(self.DEFAULT_VIEW) self.loaded_file_path = None self.trace_data = None + # Load the new file self.load_file(file_name) - def on_menu_file_close(self, menu): + def on_menu_file_close(self, menu: str) -> None: ''' Handle a File->Close selection event from the menu bar. Args: - menu: str - The menu name to load + menu: The menu name being triggered. ''' + del menu + # Deactivate all the plugins self.view.deactivate() for view in self.loaded_views.values(): @@ -288,32 +288,27 @@ def on_menu_file_close(self, menu): self.trace_data = None # Make file-based options non-sensitive - for menu in self.menus_needing_file: - menu.set_sensitive(False) + for menu_item in self.menus_needing_file: + menu_item.set_sensitive(False) self.status.log('File closed') - def on_menu_file_exit(self, menu): + def on_menu_file_exit(self, menu: str) -> None: ''' Handle a File->Exit selection event from the menu bar. Args: - menu: str - The menu name to load - ''' - ''' - if self.fileLoaded: - self.on_MenuFileClose(menu) + menu: The menu name being triggered. ''' + del menu Gtk.main_quit() - def on_menu_view(self, menu): + def on_menu_view(self, menu: str) -> None: ''' Handle a View selection event from the menu bar. Args: - menu: str - The menu name to load + menu: The menu name being triggered. ''' # Don't do anything if the old view is the new view ... if self.view == self.loaded_views[menu]: @@ -423,19 +418,18 @@ def remove_child_menus(self, child_menus): self.menubar.remove(menu) @staticmethod - def decode_key_modifiers(event): + def decode_key_modifiers(event: Union[Gdk.EventKey, Gdk.EventButton]): ''' Utility method to decode keyboard modifier keys (shift, control, alt) for the current event. Args: - event: Gtk.Event - The action event from GTK + event: The action event from GTK. Returns: - Returns a string containing the characters 'a', 'c', and/or 's' - if the relevant modifier is present. Modifier characters are - always present in the string in alphabetical order. + A string containing the characters 'a', 'c', and/or 's' if the + modifier is present. Modifier characters are always in + alphabetical order. ''' # Linux doesn't have a default mapping for Right Alt / AltGR mask_alt2 = 0 @@ -456,18 +450,18 @@ def decode_key_modifiers(event): return ''.join(mod) @classmethod - def decode_mouse_event(cls, event): + def decode_mouse_event( + cls, event: Gdk.EventButton) -> tuple[int, int, Optional[str], str]: ''' Utility method to decode a mouse event into parameters. Args: - event: Gtk.Event - The action event from GTK + event: The action event from GTK. Returns: - Returns a four element tuple containing the following elements: + A four element tuple containing the following elements: - ( x-coord, y-coord, button, key-modifier) + (x-coord, y-coord, button, key-modifier) The button is one of 'left', 'right', or 'middle' (or None if the button is not supported). The key modifier is an 'acs' string @@ -491,7 +485,7 @@ def decode_mouse_event(cls, event): return (event.x, event.y, click_button, key_modifier) - def on_key_press(self, _unused, event): + def on_key_press(self, _unused, event: Gdk.EventKey): ''' Handle a key press event on the top level window. @@ -501,7 +495,7 @@ def on_key_press(self, _unused, event): Args: _unused : N/A Unused; only provided for API compatibility for GTK callback - event: Gtk.Event + event: Gdk.Event The action event from GTK ''' key_name = Gdk.keyval_name(event.keyval) @@ -528,7 +522,7 @@ def on_key_press(self, _unused, event): return True - def on_mouse_press(self, _unused, event): + def on_mouse_press(self, _unused, event: Gdk.EventButton) -> bool: ''' Handle a mouse button press event on the top level window. @@ -536,10 +530,10 @@ def on_mouse_press(self, _unused, event): currently active `View` plugin for processing. Args: - _unused : N/A - Unused; only provided for API compatibility for GTK callback - event: Gtk.Event - The action event from GTK + event: The action event from GTK + + Returns: + True if we handled the event, False otherwise. ''' self.set_raw_mouse_event(event) @@ -571,7 +565,7 @@ def on_mouse_press(self, _unused, event): return True - def on_mouse_release(self, _unused, event): + def on_mouse_release(self, _unused, event: Gdk.EventButton) -> bool: ''' Handle a mouse button release event on the top level window. @@ -579,10 +573,10 @@ def on_mouse_release(self, _unused, event): currently active `View` plugin for processing. Args: - _unused : N/A - Unused; only provided for API compatibility for GTK callback - event: Gtk.Event - The action event from GTK + event: The action event from GTK + + Returns: + True if we handled the event, False otherwise. ''' self.set_raw_mouse_event(event) @@ -609,7 +603,7 @@ def on_mouse_release(self, _unused, event): return True - def on_mouse_move(self, _unused, event): + def on_mouse_move(self, _unused, event: Gdk.EventMotion) -> bool: ''' Handle a mouse move event on the top level window. @@ -623,10 +617,10 @@ def on_mouse_move(self, _unused, event): make it to the plugin. Args: - _unused : N/A - Unused; only provided for API compatibility for GTK callback - event: Gtk.Event - The action event from GTK + event: The action event from GTK + + Returns: + True if we handled the event, False otherwise. ''' self.set_raw_mouse_event(event) @@ -655,7 +649,7 @@ def on_mouse_move(self, _unused, event): # Consume the event return True - def on_mouse_scroll(self, _unused, event): + def on_mouse_scroll(self, _unused, event: Gdk.EventScroll) -> bool: ''' Handle a mouse wheel scroll event on the top level window. @@ -663,10 +657,10 @@ def on_mouse_scroll(self, _unused, event): hardware is supported. Args: - _unused : N/A - Unused; only provided for API compatibility for GTK callback - event: Gtk.Event - The action event from GTK + event: The action event from GTK + + Returns: + True if we handled the event, False otherwise. ''' self.set_raw_mouse_event(event) @@ -684,29 +678,27 @@ def on_mouse_scroll(self, _unused, event): return True - def on_window_size(self, _unused, allocation): + def on_window_size(self, _unused, allocation: Gdk.Rectangle) -> None: ''' Handle the initial window allocation size. + + Args: + allocation: The new window size. ''' self.width = allocation.width self.height = allocation.height - def on_window_resize(self, window): + def on_window_resize(self, _unused) -> None: ''' Handle a window resize event. - - Args: - _unused : N/A - Unused; only provided for API compatibility for GTK callback ''' self.view.resize() self.canvas.queue_draw() - def on_window_draw(self, _unused, _unused2): + def on_window_draw(self, _unused, _unused2) -> None: ''' Handle a window redraw event. ''' - # TODO: Possible to minimize redraw size with GTK3? redraw_pos = (0, 0) redraw_dim = (self.width, self.height) self.view.draw_view(redraw_pos, redraw_dim)