# -*- coding: utf-8; mode: python; -*- # # GStreamer Debug Viewer - View and analyze GStreamer debug log files # # Copyright (C) 2007 René Stadler # # 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 . """GStreamer Debug Viewer timeline widget plugin.""" import logging from GstDebugViewer import Common, Data from GstDebugViewer.GUI.colors import LevelColorThemeTango, ThreadColorThemeTango from GstDebugViewer.Plugins import FeatureBase, PluginBase from gettext import gettext as _ from gi.repository import GObject from gi.repository import Gtk from gi.repository import Gdk import cairo def iter_model_reversed(model): count = model.iter_n_children(None) for i in range(count - 1, 0, -1): yield model[i] class LineFrequencySentinel (object): def __init__(self, model): self.model = model self.clear() def clear(self): self.data = None self.n_partitions = None self.partitions = None self.step = None self.ts_range = None def _search_ts(self, target_ts, first_index, last_index): model_get = self.model.get_value model_iter_nth_child = self.model.iter_nth_child col_id = self.model.COL_TIME # TODO: Rewrite using a lightweight view object + bisect. while True: middle = (last_index - first_index) // 2 + first_index if middle == first_index: return first_index ts = model_get(model_iter_nth_child(None, middle), col_id) if ts < target_ts: first_index = middle elif ts > target_ts: last_index = middle else: return middle def run_for(self, n): if n == 0: raise ValueError("illegal value for n") self.n_partitions = n def process(self): model = self.model result = [] partitions = [] first_ts = None for row in self.model: first_ts = row[model.COL_TIME] if first_ts is not None: break if first_ts is None: return last_ts = None i = 0 UNPARSABLE_LIMIT = 500 for row in iter_model_reversed(self.model): last_ts = row[model.COL_TIME] # FIXME: We ignore 0 here (unparsable lines!), this should be # handled differently! i += 1 if i == UNPARSABLE_LIMIT: break if last_ts: last_index = row.path[0] break if last_ts is None or last_ts < first_ts: return step = int(float(last_ts - first_ts) / float(self.n_partitions)) YIELD_LIMIT = 100 limit = YIELD_LIMIT first_index = 0 target_ts = first_ts + step old_found = 0 while target_ts < last_ts: limit -= 1 if limit == 0: limit = YIELD_LIMIT yield True found = self._search_ts(target_ts, first_index, last_index) result.append(found - old_found) partitions.append(found) old_found = found first_index = found target_ts += step if step == 0: result = [] partitions = [] self.step = step self.data = result self.partitions = partitions self.ts_range = (first_ts, last_ts,) class LevelDistributionSentinel (object): def __init__(self, freq_sentinel, model): self.freq_sentinel = freq_sentinel self.model = model self.data = [] def clear(self): del self.data[:] def process(self): MAX_LEVELS = 9 YIELD_LIMIT = 10000 y = YIELD_LIMIT model_get = self.model.get_value model_next = self.model.iter_next id_time = self.model.COL_TIME id_level = self.model.COL_LEVEL del self.data[:] data = self.data i = 0 partitions_i = 0 partitions = self.freq_sentinel.partitions counts = [0] * MAX_LEVELS tree_iter = self.model.get_iter_first() if not partitions: return level_index = 0 level_iter = None finished = False while tree_iter: y -= 1 if y == 0: y = YIELD_LIMIT yield True if level_iter is None: stop_index = level_index + 512 levels = self.model.get_value_range(id_level, level_index, stop_index) level_index = stop_index level_iter = iter(levels) try: level = level_iter.__next__() except StopIteration: level_iter = None continue while i > partitions[partitions_i]: data.append(tuple(counts)) counts = [0] * MAX_LEVELS partitions_i += 1 if partitions_i == len(partitions): finished = True break if finished: break counts[level] += 1 i += 1 # Now handle the last one: data.append(tuple(counts)) yield False class UpdateProcess (object): def __init__(self, freq_sentinel, dist_sentinel): self.freq_sentinel = freq_sentinel self.dist_sentinel = dist_sentinel self.is_running = False self.dispatcher = Common.Data.GSourceDispatcher() def __process(self): if self.freq_sentinel is None or self.dist_sentinel is None: return self.is_running = True for x in self.freq_sentinel.process(): yield True self.handle_sentinel_finished(self.freq_sentinel) for x in self.dist_sentinel.process(): yield True self.handle_sentinel_progress(self.dist_sentinel) self.is_running = False self.handle_sentinel_finished(self.dist_sentinel) self.handle_process_finished() yield False def run(self): if self.is_running: return self.dispatcher(self.__process()) def abort(self): if not self.is_running: return self.dispatcher.cancel() self.is_running = False def handle_sentinel_progress(self, sentinel): pass def handle_sentinel_finished(self, sentinel): pass def handle_process_finished(self): pass class VerticalTimelineWidget (Gtk.DrawingArea): __gtype_name__ = "GstDebugViewerVerticalTimelineWidget" def __init__(self, log_view): GObject.GObject.__init__(self) self.logger = logging.getLogger("ui.vtimeline") self.log_view = log_view self.theme = ThreadColorThemeTango() self.params = None self.thread_colors = {} self.next_thread_color = 0 try: self.set_tooltip_text(_("Vertical timeline\n" "Different colors represent different threads")) except AttributeError: # Compatibility. pass def do_draw(self, ctx): alloc = self.get_allocation() x = alloc.x y = alloc.y w = alloc.width h = alloc.height # White background rectangle. ctx.set_line_width(0.) ctx.rectangle(0, 0, w, h) ctx.set_source_rgb(1., 1., 1.) ctx.fill() ctx.new_path() if self.params is None: self.__update_params() if self.params is None: return first_y, cell_height, data = self.params if len(data) < 2: return first_ts, last_ts = data[0][0], data[-1][0] ts_range = last_ts - first_ts if ts_range == 0: return ctx.set_line_width(1.) ctx.set_source_rgb(0., 0., 0.) half_height = cell_height // 2 - .5 quarter_height = cell_height // 4 - .5 first_y += half_height for i, i_data in enumerate(data): ts, thread = i_data if thread in self.thread_colors: ctx.set_source_rgb(*self.thread_colors[thread]) else: self.next_thread_color += 1 if self.next_thread_color == len(self.theme.colors): self.next_thread_color = 0 color = self.theme.colors[ self.next_thread_color][0].float_tuple() self.thread_colors[thread] = color ctx.set_source_rgb(*color) ts_fraction = float(ts - first_ts) / ts_range ts_offset = ts_fraction * h row_offset = first_y + i * cell_height ctx.move_to(-.5, ts_offset) ctx.line_to(half_height, ts_offset) ctx.line_to(w - quarter_height, row_offset) ctx.stroke() ctx.line_to(w - quarter_height, row_offset) ctx.line_to(w + .5, row_offset - half_height) ctx.line_to(w + .5, row_offset + half_height) ctx.fill() return True def do_configure_event(self, event): self.params = None self.queue_draw() return False def do_get_preferred_width(self): return 64, 64 # FIXME def clear(self): self.params = None self.thread_colors.clear() self.next_thread_color = 0 self.queue_draw() def __update_params(self): # FIXME: Ideally we should take the vertical position difference of the # view into account (which is 0 with the current UI layout). view = self.log_view model = view.get_model() visible_range = view.get_visible_range() if visible_range is None: return start_path, end_path = visible_range if not start_path or not end_path: return column = view.get_column(0) bg_rect = view.get_background_area(start_path, column) cell_height = bg_rect.height cell_rect = view.get_cell_area(start_path, column) try: first_y = view.convert_bin_window_to_widget_coords( cell_rect.x, cell_rect.y)[1] except (AttributeError, SystemError,): # AttributeError is with PyGTK before 2.12. SystemError is raised # with PyGTK 2.12.0, pygtk bug #479012. first_y = cell_rect.y % cell_height global _warn_tree_view_coords try: _warn_tree_view_coords except NameError: self.logger.warning("tree view coordinate conversion method " "not available, using aproximate offset") # Only warn once: _warn_tree_view_coords = True data = [] tree_iter = model.get_iter(start_path) if tree_iter is None: return while model.get_path(tree_iter) != end_path: data.append( model.get(tree_iter, model.COL_TIME, model.COL_THREAD)) tree_iter = model.iter_next(tree_iter) self.params = (first_y, cell_height, data,) def update(self): self.params = None self.queue_draw() class TimelineWidget (Gtk.DrawingArea): __gtype_name__ = "GstDebugViewerTimelineWidget" __gsignals__ = {"change-position": (GObject.SignalFlags.RUN_LAST, None, (GObject.TYPE_INT,),)} def __init__(self): GObject.GObject.__init__(self) self.logger = logging.getLogger("ui.timeline") self.add_events(Gdk.EventMask.BUTTON1_MOTION_MASK | Gdk.EventMask.BUTTON_PRESS_MASK | Gdk.EventMask.BUTTON_RELEASE_MASK) self.process = UpdateProcess(None, None) self.process.handle_sentinel_progress = self.__handle_sentinel_progress self.process.handle_sentinel_finished = self.__handle_sentinel_finished self.model = None self.__offscreen = None self.__offscreen_size = (0, 0) self.__offscreen_dirty = (0, 0) self.__position_ts_range = None try: self.set_tooltip_text(_("Log event histogram\n" "Different colors represent different log-levels")) except AttributeError: # Compatibility. pass def __handle_sentinel_progress(self, sentinel): if sentinel == self.process.dist_sentinel: old_progress = self.__dist_sentinel_progress new_progress = len(sentinel.data) if new_progress - old_progress >= 32: self.__invalidate_offscreen(old_progress, new_progress) self.__dist_sentinel_progress = new_progress def __handle_sentinel_finished(self, sentinel): if sentinel == self.process.freq_sentinel: self.__invalidate_offscreen(0, -1) else: self.__invalidate_offscreen(self.__dist_sentinel_progress, -1) def __ensure_offscreen(self): alloc = self.get_allocation() if self.__offscreen_size == (alloc.width, alloc.height): return self.__offscreen = cairo.ImageSurface( cairo.FORMAT_ARGB32, alloc.width, alloc.height) self.__offscreen_size = (alloc.width, alloc.height) self.__offscreen_dirty = (0, alloc.width) if not self.__offscreen: self.__offscreen_size = (0, 0) raise ValueError("could not obtain offscreen image surface") def __invalidate_offscreen(self, start, stop): alloc = self.get_allocation() if stop < 0: stop += alloc.width dirty_start, dirty_stop = self.__offscreen_dirty if dirty_start != dirty_stop: dirty_start = min(dirty_start, start) dirty_stop = max(dirty_stop, stop) else: dirty_start = start dirty_stop = stop self.__offscreen_dirty = (dirty_start, dirty_stop) # Just like in __draw_offscreen. FIXME: Need this in one place! start -= 8 stop += 8 self.queue_draw_area(start, 0, stop - start, alloc.height) def __draw_from_offscreen(self, ctx): if not self.props.visible: return alloc = self.get_allocation() offscreen_width, offscreen_height = self.__offscreen_size rect = Gdk.Rectangle() # TODO: damage region rect.x, rect.y, rect.width, rect.height = 0, 0, alloc.width, alloc.height # Fill the background (where the offscreen pixmap doesn't fit) with # white. This happens after enlarging the window, until all sentinels # have finished running. if offscreen_width < alloc.width or offscreen_height < alloc.height: ctx.rectangle(rect.x, rect.y, rect.width, rect.height) ctx.clip() if offscreen_width < alloc.width: ctx.rectangle( offscreen_width, 0, alloc.width, offscreen_height) if offscreen_height < alloc.height: ctx.new_path() ctx.rectangle(0, offscreen_height, alloc.width, alloc.height) ctx.set_line_width(0.) ctx.set_source_rgb(1., 1., 1.) ctx.fill() ctx.set_source_surface(self.__offscreen) ctx.rectangle(rect.x, rect.y, rect.width, rect.height) ctx.paint() self.__draw_position(ctx, clip=rect) def update(self, model): self.clear() self.model = model if model is not None: self.__dist_sentinel_progress = 0 self.process.freq_sentinel = LineFrequencySentinel(model) self.process.dist_sentinel = LevelDistributionSentinel( self.process.freq_sentinel, model) width = self.get_allocation().width self.process.freq_sentinel.run_for(width) self.process.run() def clear(self): self.model = None self.process.abort() self.process.freq_sentinel = None self.process.dist_sentinel = None self.__invalidate_offscreen(0, -1) def update_position(self, start_ts, end_ts): if not self.process.freq_sentinel: return if not self.process.freq_sentinel.data: return alloc = self.get_allocation() # Queue old position rectangle for redraw: if self.__position_ts_range is not None: start, stop = self.ts_range_to_position(*self.__position_ts_range) self.queue_draw_area(start - 1, 0, stop - start + 2, alloc.height) # And the new one: start, stop = self.ts_range_to_position(start_ts, end_ts) self.queue_draw_area(start - 1, 0, stop - start + 2, alloc.height) self.__position_ts_range = (start_ts, end_ts,) def find_indicative_time_step(self): MINIMUM_PIXEL_STEP = 32 time_per_pixel = self.process.freq_sentinel.step return 32 # FIXME use self.freq_sentinel.step and len (self.process.freq_sentinel.data) def __draw_offscreen(self): dirty_start, dirty_stop = self.__offscreen_dirty if dirty_start == dirty_stop: return self.__offscreen_dirty = (0, 0) width, height = self.__offscreen_size ctx = cairo.Context(self.__offscreen) # Indicator (triangle) size is 8, so we need to draw surrounding areas # a bit: dirty_start -= 8 dirty_stop += 8 dirty_start = max(dirty_start, 0) dirty_stop = min(dirty_stop, width) ctx.rectangle(dirty_start, 0., dirty_stop, height) ctx.clip() # White background rectangle. ctx.set_line_width(0.) ctx.rectangle(0, 0, width, height) ctx.set_source_rgb(1., 1., 1.) ctx.fill() ctx.new_path() # Horizontal reference lines. ctx.set_line_width(1.) ctx.set_source_rgb(.95, .95, .95) for i in range(height // 16): y = i * 16 - .5 ctx.move_to(0, y) ctx.line_to(width, y) ctx.stroke() if self.process.freq_sentinel is None: return # Vertical reference lines. pixel_step = self.find_indicative_time_step() ctx.set_source_rgb(.9, .9, .9) start = dirty_start - dirty_start % pixel_step for x in range(start + pixel_step, dirty_stop, pixel_step): ctx.move_to(x - .5, 0) ctx.line_to(x - .5, height) ctx.stroke() if not self.process.freq_sentinel.data: self.logger.debug("frequency sentinel has no data yet") return ctx.translate(dirty_start, 0.) maximum = max(self.process.freq_sentinel.data) ctx.set_source_rgb(0., 0., 0.) data = self.process.freq_sentinel.data[dirty_start:dirty_stop] self.__draw_graph(ctx, height, maximum, data) if not self.process.dist_sentinel.data: self.logger.debug("level distribution sentinel has no data yet") return colors = LevelColorThemeTango().colors dist_data = self.process.dist_sentinel.data[dirty_start:dirty_stop] def cumulative_level_counts(*levels): for level_counts in dist_data: yield sum((level_counts[level] for level in levels)) level = Data.debug_level_info levels_prev = (Data.debug_level_trace, Data.debug_level_fixme, Data.debug_level_log, Data.debug_level_debug,) ctx.set_source_rgb(*(colors[level][1].float_tuple())) self.__draw_graph(ctx, height, maximum, list(cumulative_level_counts(level, *levels_prev))) level = Data.debug_level_debug levels_prev = (Data.debug_level_trace, Data.debug_level_fixme, Data.debug_level_log,) ctx.set_source_rgb(*(colors[level][1].float_tuple())) self.__draw_graph(ctx, height, maximum, list(cumulative_level_counts(level, *levels_prev))) level = Data.debug_level_log levels_prev = (Data.debug_level_trace, Data.debug_level_fixme,) ctx.set_source_rgb(*(colors[level][1].float_tuple())) self.__draw_graph(ctx, height, maximum, list(cumulative_level_counts(level, *levels_prev))) level = Data.debug_level_fixme levels_prev = (Data.debug_level_trace,) ctx.set_source_rgb(*(colors[level][1].float_tuple())) self.__draw_graph(ctx, height, maximum, list(cumulative_level_counts(level, *levels_prev))) level = Data.debug_level_trace ctx.set_source_rgb(*(colors[level][1].float_tuple())) self.__draw_graph(ctx, height, maximum, [ counts[level] for counts in dist_data]) # Draw error and warning triangle indicators: def triangle(ctx, size=8): ctx.move_to(-size // 2, 0) ctx.line_to((size + 1) // 2, 0) ctx.line_to(0, size / 1.41) ctx.close_path() for level in (Data.debug_level_warning, Data.debug_level_error,): ctx.set_source_rgb(*(colors[level][1].float_tuple())) for i, counts in enumerate(dist_data): if counts[level] == 0: continue ctx.translate(i, 0.) triangle(ctx) ctx.fill() ctx.translate(-i, 0.) def __draw_graph(self, ctx, height, maximum, data): if not data: return if maximum: heights = [height * float(d) / maximum for d in data] else: heights = [0. for d in data] ctx.move_to(0, height) for i in range(len(heights)): ctx.line_to(i - .5, height - heights[i] + .5) ctx.line_to(i, height) ctx.close_path() ctx.fill() def __have_position(self): if ((self.process is not None) and (self.process.freq_sentinel is not None) and (self.process.freq_sentinel.ts_range is not None)): return True else: return False def ts_range_to_position(self, start_ts, end_ts): if not self.__have_position(): return (0, 0) first_ts, last_ts = self.process.freq_sentinel.ts_range step = self.process.freq_sentinel.step if step == 0: return (0, 0) position1 = int(float(start_ts - first_ts) / step) position2 = int(float(end_ts - first_ts) / step) return (position1, position2) def __draw_position(self, ctx, clip=None): if not self.__have_position() or self.__position_ts_range is None: if not self.__have_position(): self.logger.debug("have no positions") else: self.logger.debug("have no positions_ts_range") return start_ts, end_ts = self.__position_ts_range position1, position2 = self.ts_range_to_position(start_ts, end_ts) if clip: if clip.x + clip.width < position1 - 1 or clip.x > position2 + 1: self.logger.debug( "outside of clip range: %d + %d, pos: %d, %d", clip.x, clip.width, position1, position2) return ctx.rectangle(clip.x, clip.y, clip.width, clip.height) ctx.clip() height = self.get_allocation().height line_width = position2 - position1 if line_width <= 1: ctx.set_source_rgb(1., 0., 0.) ctx.set_line_width(1.) ctx.move_to(position1 + .5, 0) ctx.line_to(position1 + .5, height) ctx.stroke() else: ctx.set_source_rgba(1., 0., 0., .5) ctx.rectangle(position1, 0, line_width, height) ctx.fill() def do_draw(self, cr): self.__ensure_offscreen() self.__draw_offscreen() self.__draw_from_offscreen(cr) return True def do_configure_event(self, event): self.logger.debug("widget size configured to %ix%i", event.width, event.height) if event.width < 16: return False self.update(self.model) return False def do_get_preferred_height(self): return 64, 64 # FIXME: def do_button_press_event(self, event): if event.button != 1: return False # TODO: Check if clicked inside a warning/error indicator triangle and # navigate there. if not self.has_grab(): self.grab_add() self.props.has_tooltip = False pos = int(event.x) self.emit("change-position", pos) return True def do_button_release_event(self, event): if event.button != 1: return False if self.has_grab(): self.grab_remove() self.props.has_tooltip = True return True def do_motion_notify_event(self, event): if event.get_state() & Gdk.ModifierType.BUTTON1_MASK: self.emit("change-position", int(event.x)) Gdk.event_request_motions(event) return True else: self._handle_motion(event.x, event.y) Gdk.event_request_motions(event) return False def _handle_motion(self, x, y): # TODO: Prelight warning and error indicator triangles. pass class AttachedWindow (object): def __init__(self, feature, window): self.window = window ui = window.ui_manager ui.insert_action_group(feature.action_group, 0) self.merge_id = ui.new_merge_id() ui.add_ui(self.merge_id, "/menubar/ViewMenu/ViewMenuAdditions", "ViewTimeline", "show-timeline", Gtk.UIManagerItemType.MENUITEM, False) ui.add_ui(self.merge_id, "/", "TimelineContextMenu", None, Gtk.UIManagerItemType.POPUP, False) # TODO: Make hide before/after operate on the partition that the mouse # is pointed at instead of the currently selected line. # ui.add_ui (self.merge_id, "/TimelineContextMenu", "TimelineHideLinesBefore", # "hide-before-line", Gtk.UIManagerItemType.MENUITEM, False) # ui.add_ui (self.merge_id, "/TimelineContextMenu", "TimelineHideLinesAfter", # "hide-after-line", Gtk.UIManagerItemType.MENUITEM, False) ui.add_ui( self.merge_id, "/TimelineContextMenu", "TimelineShowHiddenLines", "show-hidden-lines", Gtk.UIManagerItemType.MENUITEM, False) box = window.get_top_attach_point() self.timeline = TimelineWidget() self.timeline.connect("change-position", self.handle_timeline_change_position) box.pack_start(self.timeline, False, False, 0) self.timeline.hide() self.popup = ui.get_widget("/TimelineContextMenu") Common.GUI.widget_add_popup_menu(self.timeline, self.popup) box = window.get_side_attach_point() self.vtimeline = VerticalTimelineWidget(self.window.log_view) box.pack_start(self.vtimeline, False, False, 0) self.vtimeline.hide() handler = self.handle_log_view_adjustment_value_changed adjustment = window.widgets.log_view_scrolled_window.props.vadjustment adjustment.connect("value-changed", handler) handler = self.handle_show_action_toggled action = feature.action_group.get_action("show-timeline") action.connect("toggled", handler) handler(action) handler = self.handle_log_view_notify_model self.notify_model_id = window.log_view.connect( "notify::model", handler) self.idle_scroll_path = None self.idle_scroll_id = None def detach(self, feature): self.window.log_view.disconnect(self.notify_model_id) self.notify_model_id = None self.window.ui_manager.remove_ui(self.merge_id) self.merge_id = None self.window.ui_manager.remove_action_group(feature.action_group) self.timeline.destroy() self.timeline = None self.idle_scroll_path = None if self.idle_scroll_id is not None: GObject.source_remove(self.idle_scroll_id) self.idle_scroll_id = None def handle_detach_log_file(self, log_file): self.timeline.clear() self.vtimeline.clear() def handle_log_view_notify_model(self, view, gparam): model = view.get_model() if model is None: self.timeline.clear() self.vtimeline.clear() return self.timeline.update(model) # Need to dispatch these idly with a low priority to avoid triggering a # warning in treeview.get_visible_range: def idle_update(): self.update_timeline_position() self.vtimeline.update() return False GObject.idle_add(idle_update, priority=GObject.PRIORITY_LOW) def handle_log_view_adjustment_value_changed(self, adj): # FIXME: If not visible, disconnect this handler! if not self.timeline.props.visible: return self.update_timeline_position() self.vtimeline.update() def update_timeline_position(self): visible_range = self.window.get_range() if visible_range is None: return ts1, ts2 = visible_range self.timeline.update_position(ts1, ts2) def handle_show_action_toggled(self, action): show = action.props.active if show: self.timeline.show() self.vtimeline.show() else: self.timeline.hide() self.vtimeline.hide() def handle_timeline_change_position(self, widget, pos): self.goto_time_position(pos) def goto_time_position(self, pos): if not self.timeline.process.freq_sentinel: return True data = self.timeline.process.freq_sentinel.data if not data: return True if pos < 0: pos = 0 elif pos >= len(data): pos = len(data) - 1 count = sum(data[:pos + 1]) path = (count,) self.idle_scroll_path = path if self.idle_scroll_id is None: self.idle_scroll_id = GObject.idle_add(self.idle_scroll) return False def idle_scroll(self): self.idle_scroll_id = None if self.idle_scroll_path is None: return False path = self.idle_scroll_path self.idle_scroll_path = None view = self.window.log_view view.scroll_to_cell(path, use_align=True, row_align=.5) return False class TimelineFeature (FeatureBase): def __init__(self, app): self.logger = logging.getLogger("ui.timeline") self.action_group = Gtk.ActionGroup("TimelineActions") self.action_group.add_toggle_actions([("show-timeline", None, _("_Timeline"), "t")]) self.state = app.state.sections[TimelineState._name] self.attached_windows = {} action = self.action_group.get_action("show-timeline") action.props.active = self.state.shown action.connect("toggled", self.handle_show_action_toggled) def handle_show_action_toggled(self, action): self.state.shown = action.props.active def handle_attach_window(self, window): self.attached_windows[window] = AttachedWindow(self, window) def handle_detach_window(self, window): attached_window = self.attached_windows.pop(window) attached_window.detach(self) def handle_attach_log_file(self, window, log_file): pass def handle_detach_log_file(self, window, log_file): attached_window = self.attached_windows[window] attached_window.handle_detach_log_file(log_file) class TimelineState (Common.GUI.StateSection): _name = "timeline" shown = Common.GUI.StateBool("shown", default=True) class Plugin (PluginBase): features = (TimelineFeature,) def __init__(self, app): app.state.add_section_class(TimelineState) self.state = app.state.sections[TimelineState._name]