class ScrollMenu(clutter.Group, object): """Menu widget that contains text items.""" __gtype_name__ = 'ScrollMenu' __gsignals__ = { 'activated': (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, ()), 'selected': (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, ()), 'moved': (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, ()), } MODE_SELECTION = 0 MODE_MOTION = 1 MODE_STOP = 2 def __init__(self, item_gap, item_height, font_size, color_name): clutter.Group.__init__(self) self._motion_buffer = MotionBuffer() self._items = [] self._item_gap = item_gap self._item_height = item_height self._item_font_size = font_size self._item_color_name = color_name self._selected_index = 1 self._visible_items = 5 self._event_mode = -1 self._animation_progression = 0 self._animation_start_index = 1 self._animation_end_index = 1 self._active = False self._motion_handler = 0 self._timeline = clutter.Timeline(300) self._alpha = clutter.Alpha(self._timeline, clutter.EASE_IN_OUT_SINE) # preparation to pointer events handling self.set_reactive(True) self.connect('scroll-event', self._on_scroll_event) self.connect('button-press-event', self._on_button_press_event) self.connect('button-release-event', self._on_button_release_event) def refresh(self): """Refresh the menu: clip area dimensions and items positions""" self._set_selected_index(self._selected_index, 1) self._set_visible_items(self._visible_items) def add_item(self, text, name): """Creation of a new MenuItem and addition to the ScrollMenu""" item = ScrollMenuItem(self._alpha, text, self._item_height, self._item_font_size, self._item_color_name) item.set_name(name) item.connect('notify::y', self._update_item_opacity) self.add(item) self._items.append(item) self._update_item_opacity(item) def remove_item(self, name): """Remove an item from the menu""" index = self.get_index(name) if index != -1: # if item was found, we remove it from the item list, from the # group and finally we delete it. item = self._items[index] self._items.remove(item) self.remove(item) del item def _get_active(self): """Active property getter""" return self._active def _set_active(self, boolean): """Active property setter""" if self._active == boolean: return self._active = boolean if boolean: self.set_opacity(255) self.emit('activated') else: self.set_opacity(128) active = property(_get_active, _set_active) def stop_animation(self): '''Stops the timeline driving menu animation.''' self._timeline.stop() def _update_behaviours(self, target): """Preparation of behaviours applied to menu items before animation""" items_len = len(self._items) step = 1.0 / items_len step_pix = self._item_gap + self._item_height middle_index = int(self._visible_items / 2) + 1 for x, item in enumerate(self._items): item.behaviour.start_index = (x + middle_index - \ self._selected_index) * step item.behaviour.end_index = (x + middle_index - target) * step item.behaviour.start_knot = (0.0, -step_pix) item.behaviour.end_knot = (0.0, (items_len - 1.0) * step_pix) def _display_items_at_target(self, target): """Menu is displayed for a particular targeted index value""" step = 1.0 / len(self._items) middle_index = int(self._visible_items / 2) + 1 for x, item in enumerate(self._items): raw_index = (x + middle_index - target) * step if raw_index >= 0: index = math.modf(raw_index)[0] else: index = 1 + math.modf(raw_index)[0] # Calculation of new coordinates xx = index * (item.behaviour.end_knot[0] - \ item.behaviour.start_knot[0]) + item.behaviour.start_knot[0] yy = index * (item.behaviour.end_knot[1] - \ item.behaviour.start_knot[1]) + item.behaviour.start_knot[1] item.set_position(int(xx), int(yy)) def _get_visible_items(self): """visible_items property getter""" return self._visible_items def _set_visible_items(self, visible_items): """visible_items property setter""" self._visible_items = visible_items height = visible_items * self._item_height + (visible_items - 1) * \ self._item_gap self.set_clip(0, 0, self.get_width(), height) visible_items = property(_get_visible_items, _set_visible_items) def _get_selected_index(self): """selected_index property getter""" return self._selected_index def _set_selected_index(self, selected_index, duration=300): """selected_index property setter""" if not self._timeline.is_playing(): items_len = len(self._items) self._update_behaviours(selected_index) # those 2 variables are used if we want to stop the timeline # we use them + timeline progression to calculate the current index # when (if) we stop self._animation_start_index = self._selected_index self._animation_end_index = selected_index # selected_index can be any desired value but in the end, # we have to rescale it to be between 0 and (items_len-1) if selected_index >= 0: self._selected_index = selected_index - \ math.modf(selected_index / items_len)[1] * items_len else: self._selected_index = selected_index + \ (math.modf(-(selected_index + 1) / items_len)[1] + 1) * \ items_len self._timeline.set_duration(duration) self._timeline.start() self.emit('moved') selected_index = property(_get_selected_index, _set_selected_index) def get_selected(self): """Get currently selected menuitem""" return self._items[int(self._selected_index)] def get_index(self, text): """Returns index of label with the text as passed or -1 if not found""" for item in self._items: if item.get_name() == text: return self._items.index(item) return -1 def scroll_by(self, step, duration=300): """Generic scroll of menu items""" self._set_selected_index(self._selected_index + step, duration) def scroll_up(self, duration=300): """All menu items are scrolled up""" self.scroll_by(-1, duration) def scroll_down(self, duration=300): """All menu items are scrolled down""" self.scroll_by(1, duration) def get_opacity_for_y(self, y): """Calculation of actor's opacity as a function of its y coordinates""" opacity_first_item = 40 opacity_selected_item = 255 middle = int(self._visible_items / 2) y_medium_item = middle * (self._item_height + self._item_gap) a = float(opacity_selected_item - opacity_first_item) a /= float(y_medium_item) if y <= y_medium_item: opacity = y * a + opacity_first_item else: opacity = opacity_selected_item * 2 - opacity_first_item - a * y if opacity < 0: opacity = 0 return int(opacity) def _update_item_opacity(self, item, stage=None): """Set opacity to actors when they are moving. Opacity is f(y)""" opacity = self.get_opacity_for_y(item.get_y()) item.set_opacity(opacity) def _on_button_press_event(self, actor, event): """button-press-event handler""" clutter.grab_pointer(self) if not self.handler_is_connected(self._motion_handler): self._motion_handler = self.connect('motion-event', self._on_motion_event) if self._timeline.is_playing(): # before we stop the timeline, store its progression self._animation_progression = self._timeline.get_progress() # A click with an animation pending should stop the animation self._timeline.stop() # go to MODE_STOP to handle correctly next button-release event self._event_mode = self.MODE_STOP else: # no animation pending so we're going to do either a menu_item # selection or a menu motion. This will be decided later, right now # we just take a snapshot of this button-press-event as a start. self._motion_buffer.start(event) self._event_mode = self.MODE_SELECTION return False def _on_button_release_event(self, actor, event): """button-release-event handler""" items_len = len(self._items) clutter.ungrab_pointer() if self.handler_is_connected(self._motion_handler): self.disconnect_by_func(self._on_motion_event) self._motion_buffer.compute_from_last_motion_event(event) if not self.active: self.active = True return y = event.y - self.get_y() if self._event_mode == self.MODE_SELECTION: # if we are in MODE_SELECTION it means that we want to select # the menu item bellow the pointer for index, item in enumerate(self._items): item_y = item.get_y() item_h = item.get_height() if (y >= item_y) and (y <= (item_y + item_h)): delta1 = index - self._selected_index delta2 = index - self._selected_index + items_len delta3 = index - self._selected_index - items_len delta = 99999 for i in [delta1, delta2, delta3]: if math.fabs(i) < math.fabs(delta): delta = i self.scroll_by(delta) # if delta = 0 it means we've clicked on the selected item if delta == 0: self.emit('selected') elif self._event_mode == self.MODE_MOTION: speed = self._motion_buffer.speed_y_from_last_motion_event target = self._selected_index - \ self._motion_buffer.dy_from_start / \ self._items[0].behaviour.path_length * items_len new_index = int(target - 5 * speed) self._selected_index = target self._set_selected_index(new_index, 1000) else: # If we have stopped the pending animation. Now we have to do # a small other one to select the closest menu-item current_index = self._animation_start_index + \ (self._animation_end_index - self._animation_start_index) * \ self._animation_progression self._selected_index = current_index target_index = int(current_index) self._set_selected_index(target_index, 1000) return False def _on_motion_event(self, actor, event): """motion-event handler""" # threshold in pixels = the minimum distance we have to move before we # consider a motion has started motion_threshold = 10 self._motion_buffer.compute_from_start(event) if self._motion_buffer.distance_from_start > motion_threshold: self._motion_buffer.take_new_motion_event(event) self._event_mode = self.MODE_MOTION target = self._selected_index - \ self._motion_buffer.dy_from_start / \ self._items[0].behaviour.path_length * len(self._items) self._display_items_at_target(target) return False def _on_scroll_event(self, actor, event): """scroll-event handler (mouse's wheel)""" self.active = True if event.direction == clutter.SCROLL_DOWN: self.scroll_down(duration=150) else: self.scroll_up(duration=150) return False
class GridMenu(Base, clutter.Group): """ GridMenu widget. A core widget to handle MenuItem in a grid with a cursor. This widget provides all the necessary logic to move items and the cursor. """ __gsignals__ = { 'activated': (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, ()), 'moved': (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, ()), 'selected': (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, ()), } MODE_NONE = 0 MODE_SELECT = 1 MODE_SEEK = 2 def __init__(self, x=0, y=0, item_width=0.2, item_height=0.1): Base.__init__(self) clutter.Group.__init__(self) self.motion_duration = 100 # Default duration of animations ms. self.cursor_below = True # Is the cursor below items? self._active = None self._is_vertical = True self.items = [] # Items dimensions variable: relative, absolute, center self._item_width = item_width self._item_height = item_height self._item_width_abs = self.get_abs_x(item_width) self._item_height_abs = self.get_abs_y(item_height) self._dx = int(self._item_width_abs / 2) self._dy = int(self._item_height_abs / 2) # Default cursor's index. self._selected_index = 0 # Grid dimensions: real, visible. self.items_per_row = 10 self.items_per_col = 10 self._visible_rows = 3 self._visible_cols = 5 # The moving_group is a Clutter group containing all the items. self._moving_group_x = 0 self._moving_group_y = 0 self._moving_group = clutter.Group() self.add(self._moving_group) # The moving_group is translated using a `BehaviourPath`. self._moving_group_timeline = clutter.Timeline(200) moving_group_alpha = clutter.Alpha(self._moving_group_timeline, clutter.EASE_IN_OUT_SINE) moving_group_path = clutter.Path() self._moving_group_behaviour = clutter.BehaviourPath( moving_group_alpha, moving_group_path) self._moving_group_behaviour.apply(self._moving_group) # The cursor is an Actor that can be added and moved on the menu. # The cusor is always located in the visible (clipped) area of the menu. self._cursor_x = 0 self._cursor_y = 0 self._cursor = None self._cursor_timeline = clutter.Timeline(200) cursor_alpha = clutter.Alpha(self._cursor_timeline, clutter.EASE_IN_SINE) cursor_path = clutter.Path() self._cursor_behaviour = clutter.BehaviourPath(cursor_alpha, cursor_path) # A MotionBuffer is used to compute useful information about the # cursor's motion. It's used when moving the cursor with a pointer. self._motion_buffer = MotionBuffer() self._event_mode = self.MODE_NONE self._motion_handler = 0 self._seek_step_x = 0 self._seek_step_y = 0 gobject.timeout_add(200, self._internal_timer_callback) #XXX: Samuel Buffet # This rectangle is used to grab events as it turns out that their # might be a bug in clutter 0.8 or python-clutter 0.8. # It may be avoided with next release of clutter. self._event_rect = clutter.Rectangle() self._event_rect.set_opacity(0) self.add(self._event_rect) self._event_rect.set_reactive(True) self._event_rect.connect('button-press-event', self._on_button_press_event) self._event_rect.connect('button-release-event', self._on_button_release_event) self._event_rect.connect('scroll-event', self._on_scroll_event) self.set_position(self.get_abs_x(x), self.get_abs_y(y)) @property def count(self): """Return the number of items.""" return len(self.items) @property def on_top(self): """Return True if the selected item is currently on the top.""" selected_row = self._index_to_xy(self._selected_index)[1] if selected_row == 0: return True else: return False @property def on_bottom(self): """Return True if the selected item is currently on the bottom.""" selected_row = self._index_to_xy(self._selected_index)[1] if self._is_vertical: end_row = self._index_to_xy(self.count - 1)[1] if selected_row == end_row: return True else: return False else: if selected_row == self.items_per_col - 1: return True else: return False @property def on_left(self): """Return True if the selected item is currently on the left.""" selected_col = self._index_to_xy(self._selected_index)[0] if selected_col == 0: return True else: return False @property def on_right(self): """Return True if the selected item is currently on the right.""" selected_col = self._index_to_xy(self._selected_index)[0] if not self._is_vertical: end_col = self._index_to_xy(self.count - 1)[0] if selected_col == end_col: return True else: return False else: if selected_col == self.items_per_row - 1: return True else: return False @property def selected_item(self): """Return the selected MenuItem.""" if self.count == 0: return None else: return self.items[self._selected_index] @property def selected_userdata(self): """Return userdata of the MenuItem.""" item = self.selected_item if item is None: return None else: return item.userdata def _get_active(self): """Active property getter.""" return self._active def _set_active(self, boolean): """Active property setter.""" if self._active == boolean: return self._active = boolean if boolean: if self._cursor is not None: self._cursor.show() if self.selected_item is not None: self.selected_item.animate_in() self.emit('activated') self.set_opacity(255) else: if self._cursor is not None: self._cursor.hide() if self.selected_item is not None: self.selected_item.animate_out() self.set_opacity(128) active = property(_get_active, _set_active) def _get_horizontal(self): """horizontal property getter.""" return not self._is_vertical def _set_horizontal(self, boolean): """horizontal property setter.""" self._is_vertical = not boolean horizontal = property(_get_horizontal, _set_horizontal) def _get_vertical(self): """vertical property getter.""" return self._is_vertical def _set_vertical(self, boolean): """vertical property setter.""" self._is_vertical = boolean vertical = property(_get_vertical, _set_vertical) def _get_selected_index(self): """selected_index property getter.""" return self._selected_index def _set_selected_index(self, index, duration=None): """selected_index property setter.""" # Xc, Yc : coordinates of the menu's cursor on the array of items. # xc, yc : coordinates of the menu's cursor relative to the menu. # xm, ym : coordinates of the moving_group relative to the menu. # Xc = xc - xm # Yc = yc - ym if self._selected_index == index or \ index < 0 or \ index > self.count - 1 or \ self._moving_group_timeline.is_playing() or \ self._cursor_timeline.is_playing(): return # Start select/unselect animations on both items. self.items[self._selected_index].animate_out() self.items[index].animate_in() # Get the cursor's coordinate on the array. # /!\ Those coordinates are NOT pixels but refer to the array of items. (Xc, Yc) = self._index_to_xy(index) xm = self._moving_group_x ym = self._moving_group_y xc = Xc + xm yc = Yc + ym # If the targeted cursor's position is on the last visible column then # the moving_group is translated by -1 on the x axis and the translation # of the cursor is reduce by 1 to stay on the column before the last # one. This is not done if the last column has been selected. if xc == self.visible_cols - 1 and \ xm > self.visible_cols -self.items_per_row: xc -= 1 xm -= 1 # If the targeted cursor's position is on the first visible column then # the moving_group is translated by +1 on the x axis and the translation # of the cursor is raised by 1 to stay on the column after the first # one. This is not done if the first column has been selected. if xc == 0 and xm < 0: xc += 1 xm += 1 # If the targeted cursor's position is on the last visible row then # the moving_group is translated by -1 on the y axis and the translation # of the cursor is reduce by 1 to stay on the row before the last # one. This is not done if the last row has been selected. if yc == self.visible_rows - 1 and \ ym > self.visible_rows -self.items_per_col: yc -= 1 ym -= 1 # If the targeted cursor's position is on the first visible row then # the moving_group is translated by +1 on the y axis and the translation # of the cursor is raised by 1 to stay on the row after the first # one. This is not done if the last row has been selected. if yc == 0 and ym < 0: yc += 1 ym += 1 if duration is None: duration = self.motion_duration self._move_cursor(xc, yc, duration) self._move_moving_group(xm, ym, duration) self._selected_index = index self.emit('moved') selected_index = property(_get_selected_index, _set_selected_index) def _get_visible_rows(self): """visible_rows property getter.""" return self._visible_rows def _set_visible_rows(self, visible_rows): """visible_rows property setter.""" self._visible_rows = visible_rows self._clip() visible_rows = property(_get_visible_rows, _set_visible_rows) def _get_visible_cols(self): """visible_cols property getter.""" return self._visible_cols def _set_visible_cols(self, visible_cols): """visible_cols property setter.""" self._visible_cols = visible_cols self._clip() visible_cols = property(_get_visible_cols, _set_visible_cols) def _get_cursor(self): """cursor property getter.""" return self._cursor def _set_cursor(self, cursor): """cursor property setter.""" if self._cursor is not None: self.remove(self._cursor) self._cursor = cursor if self._cursor is not None: self.add(self._cursor) if self._active: self._cursor.show() else: self._cursor.hide() if self.cursor_below: self._cursor.lower_bottom() else: self._cursor.raise_top() self._cursor.set_size(int(self._item_width_abs), int(self._item_height_abs)) self._cursor.set_anchor_point(self._dx, self._dy) self._cursor.set_position(self._dx, self._dy) self._cursor_behaviour.apply(self._cursor) cursor = property(_get_cursor, _set_cursor) def _clip(self): """Updates the clipping region.""" self.set_clip(0, 0, self._visible_cols * self._item_width_abs, self._visible_rows * self._item_height_abs) self._event_rect.set_size(self._visible_cols * self._item_width_abs, self._visible_rows * self._item_height_abs) def stop_animation(self): """Stops the timelines driving menu animation.""" self._moving_group_timeline.stop() self._cursor_timeline.stop() def raw_add_item(self, item): """A method to add an item in the menu.""" self._moving_group.add(item) self.items.append(item) (x, y) = self._index_to_xy(self.count - 1) item.move_anchor_point(self._dx, self._dy) item.set_position(x * self._item_width_abs + self._dx, y * self._item_height_abs + self._dy) if self._is_vertical: self.items_per_col = y + 1 else: self.items_per_row = x + 1 if self.cursor_below: item.raise_top() else: item.lower_bottom() def _index_to_xy(self, index): """Return the coordinates of an element associated to its index.""" if self._is_vertical: r = index / float(self.items_per_row) y = int(math.modf(r)[1]) x = int(index - y * self.items_per_row) else: r = index / float(self.items_per_col) x = int(math.modf(r)[1]) y = int(index - x * self.items_per_col) return (x, y) def _move_moving_group(self, x, y, duration): """Moves the moving_group to x, y coordinates.""" if (x, y) == (self._moving_group_x, self._moving_group_y): return path = clutter.Path() path.add_move_to(self._moving_group_x * self._item_width_abs, self._moving_group_y * self._item_height_abs) path.add_line_to(x * self._item_width_abs, y * self._item_height_abs) self._moving_group_behaviour.set_path(path) self._moving_group_x, self._moving_group_y = x, y self._moving_group_timeline.set_duration(duration) self._moving_group_timeline.start() def _move_cursor(self, x, y, duration): """ Moves the cursor to x, y coordinates. The motion is applied to the center of the cursor. """ if (x, y) == (self._cursor_x, self._cursor_y): return path = clutter.Path() path.add_move_to(self._cursor_x * self._item_width_abs + self._dx, self._cursor_y * self._item_height_abs + self._dy) path.add_line_to(x * self._item_width_abs + self._dx, y * self._item_height_abs + self._dy) self._cursor_behaviour.set_path(path) self._cursor_x, self._cursor_y = x, y self._cursor_timeline.set_duration(duration) self._cursor_timeline.start() def up(self): """Move the menu's cursor up changing the selected_index property.""" if not self.on_top: if self._is_vertical: self.selected_index -= self.items_per_row else: self.selected_index -= 1 def down(self): """Move the menu's cursor down changing the selected_index property.""" if not self.on_bottom: if self._is_vertical: self.selected_index += self.items_per_row else: self.selected_index += 1 def right(self): """Move the menu's cursor right changing the selected_index property.""" if not self.on_right: if self._is_vertical: self.selected_index += 1 else: self.selected_index += self.items_per_col def left(self): """Move the menu's cursor left changing the selected_index property.""" if not self.on_left: if self._is_vertical: self.selected_index -= 1 else: self.selected_index -= self.items_per_col def _internal_timer_callback(self): """ This callback is used to move the cursor if the SEEK mode is activated. """ if self._event_mode == self.MODE_SEEK: if self._seek_step_x == 1: self.right() if self._seek_step_x == -1: self.left() if self._seek_step_y == 1: self.down() if self._seek_step_y == -1: self.up() return True def _on_button_press_event(self, actor, event): """button-press-event handler.""" clutter.grab_pointer(self._event_rect) if not self._event_rect.handler_is_connected(self._motion_handler): self._motion_handler = self._event_rect.connect( 'motion-event', self._on_motion_event) (x_menu, y_menu) = self.get_transformed_position() (x_moving_group, y_moving_group) = self._moving_group.get_position() # Events coordinates are relative to the stage. # So they need to be computed relatively to the moving group. x = event.x - x_menu - x_moving_group y = event.y - y_menu - y_moving_group x_grid = int(x / self._item_width_abs) y_grid = int(y / self._item_height_abs) if self._is_vertical: new_index = y_grid * self.items_per_row + x_grid else: new_index = x_grid * self.items_per_col + y_grid (delta_x, delta_y) = self._index_to_xy(self._selected_index) delta_x -= x_grid delta_y -= y_grid # Correction factor due to the fact that items are not necessary square, # but most probably rectangles. So the distance in the grid coordinates # must be corrected by a factor to have a real distance in pixels on the # screen. correction = float(self._item_width_abs) / float(self._item_height_abs) correction *= correction distance = math.sqrt(delta_x**2 * correction + delta_y**2) # Computation of the duration of animations, scaling grid steps to ms. duration = int(distance * 50) if self.selected_index == new_index and \ self.active and \ not self._cursor_timeline.is_playing() and \ not self._moving_group_timeline.is_playing(): self._event_mode = self.MODE_SELECT else: self.active = True self._event_mode = self.MODE_NONE self._set_selected_index(new_index, duration) self._motion_buffer.start(event) return False def _on_button_release_event(self, actor, event): """button-release-event handler.""" clutter.ungrab_pointer() if self._event_rect.handler_is_connected(self._motion_handler): self._event_rect.disconnect_by_func(self._on_motion_event) if self._event_mode == self.MODE_SELECT: self.emit('selected') self._event_mode = self.MODE_NONE return True def _on_motion_event(self, actor, event): """motion-event handler""" # threshold in pixels = the minimum distance we have to move before we # consider a motion has started motion_threshold = 20 self._seek_step_x = 0 self._seek_step_y = 0 self._motion_buffer.compute_from_start(event) self._motion_buffer.compute_from_last_motion_event(event) if self._motion_buffer.distance_from_start > motion_threshold: self._event_mode = self.MODE_SEEK self._motion_buffer.take_new_motion_event(event) dx = self._motion_buffer.dx_from_last_motion_event dy = self._motion_buffer.dy_from_last_motion_event if math.fabs(dx) > math.fabs(dy): self._seek_step_x = dx > 0 and 1 or -1 else: self._seek_step_y = dy > 0 and 1 or -1 return False def _on_scroll_event(self, actor, event): """scroll-event handler (mouse's wheel).""" if not self.active: self.active = True return if event.direction == clutter.SCROLL_DOWN: self.down() else: self.up() return False
class ScrollArea(Base, clutter.Group): """Wrapper of a clutter Group that allows for scrolling. ScrollArea modifies the width of the content and it assumes that the content uses percent modification (read: not default clutter objects).""" __gsignals__ = { 'activated': (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, ()), 'moving': (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, ()), } MODE_SELECTION = 0 MODE_MOTION = 1 MODE_STOP = 2 STEP_SIZE_PERCENT = 0.04 def __init__(self, x, y, width, height, content): Base.__init__(self) clutter.Group.__init__(self) self._motion_buffer = MotionBuffer() self._offset = 0 # Drives the content motion. self._offset_max = 0 # Maximum value of offset (equal on bottom). self._old_offset = 0 # Stores the old value of offset on motions. self._motion_handler = 0 self._active = None self.step_size = self.get_abs_y(self.STEP_SIZE_PERCENT) # Allowed area for the widget's scrolling content. self.area_width = self.get_abs_x(width) self.area_height = self.get_abs_y(height) # Create content position indicator self.indicator = ListIndicator(3 * width / 4, height, 0.2, 0.045, ListIndicator.VERTICAL) self.indicator.hide_position() self.indicator.set_maximum(2) self.add(self.indicator) # A clipped Group to receive the content. self._fixed_group = clutter.Group() self._fixed_group.set_clip(0, 0, self.area_width, self.area_height) self.add(self._fixed_group) self.content = None self._motion_timeline = clutter.Timeline(500) self._motion_timeline.connect('completed', self._motion_timeline_callback, None) self._motion_alpha = clutter.Alpha(self._motion_timeline, clutter.EASE_OUT_SINE) self._motion_behaviour = LoopedPathBehaviour(self._motion_alpha) self.set_content(content) self.active = None # Preparation to pointer events handling. self.set_reactive(True) self.connect('button-press-event', self._on_button_press_event) self.connect('button-release-event', self._on_button_release_event) self.connect('scroll-event', self._on_scroll_event) self.set_position(self.get_abs_x(x), self.get_abs_y(y)) @property def on_top(self): """True if we're on top.""" return self._offset == 0 @property def on_bottom(self): """True if we're on bottom.""" return self._offset == self._offset_max def _get_active(self): """Active property getter.""" return self._active def _set_active(self, boolean): """Active property setter.""" if self._active == boolean: return self._active = boolean if boolean: # Show indicator if there is need for scrolling. if self._offset_max >= 0: self.indicator.show() self.set_opacity(255) self.emit('activated') else: self.indicator.hide() self.set_opacity(128) active = property(_get_active, _set_active) def _get_offset(self): """Get current offset value.""" return self._offset def _set_offset(self, integer): """Set current offset value.""" if self._offset == integer: return self._offset = integer if self._offset < 0: self._offset = 0 elif self._offset > self._offset_max: self._offset = self._offset_max self.content.set_position(0, -self._offset) # Indicator updates. if self.on_top: self.indicator.set_current(1) elif self.on_bottom: self.indicator.set_current(2) offset = property(_get_offset, _set_offset) def set_content(self, content): """Set content into scroll area.""" if self.content is not None: self._fixed_group.remove(self.content) self._motion_behaviour.remove(self.content) self.content = content self._fixed_group.add(content) self._offset_max = self.content.get_height() - self.area_height self._motion_behaviour.apply(self.content) def stop_animation(self): """Stops the timeline driving animation.""" self._motion_timeline.stop() def scroll_to_top(self): """Scroll content back to top.""" self.offset = 0 def scroll_to_bottom(self): """Scroll content as much as possible.""" self.offset = self._offset_max def scroll_up(self): """Scroll up by one step size.""" self.offset -= self.step_size def scroll_down(self): """Scroll down by one step size.""" self.offset += self.step_size def scroll_page_up(self): """Scroll up by one page. Page is a scroll area height.""" self.offset -= self.area_height def scroll_page_down(self): self.offset += self.area_height def _update_motion_behaviour(self, target): """Preparation of looped behaviour applied to the content.""" self._motion_behaviour.start_knot = (0.0, -self.offset) self._motion_behaviour.end_knot = (0.0, -target) self._motion_behaviour.start_index = 0.0 # Need to set the end index to 0.9999. Indeed the LoopedPathBehaviour # uses an index in [0, 1[. So index = 1 is equivalent to index = 0, the # Actor will the be placed on the start_knot. self._motion_behaviour.end_index = 0.9999 def _on_button_press_event(self, actor, event): """button-press-event handler.""" clutter.grab_pointer(self) if not self.handler_is_connected(self._motion_handler): self._motion_handler = self.connect('motion-event', self._on_motion_event) if self._motion_timeline.is_playing(): # A click with an animation pending should stop the animation. self._motion_timeline.stop() # Go to MODE_STOP to handle correctly next button-release event. self._event_mode = self.MODE_STOP self.offset = -self.content.get_y() else: # No animation pending so we're going to do nothing or to move # all the content. self._old_offset = self.offset self._motion_buffer.start(event) self._event_mode = self.MODE_SELECTION return False def _on_button_release_event(self, actor, event): """button-release-event handler.""" clutter.ungrab_pointer() if self.handler_is_connected(self._motion_handler): self.disconnect_by_func(self._on_motion_event) self._motion_buffer.compute_from_last_motion_event(event) if not self.active: self.active = True return if self._event_mode == self.MODE_MOTION: speed = self._motion_buffer.speed_y_from_last_motion_event # Calculation of the new target according to vertical speed. target = self.offset - speed * 200 if target < 0: target = 0 elif target > self._offset_max: target = self._offset_max self._update_motion_behaviour(target) self._motion_timeline.start() return False def _on_motion_event(self, actor, event): """motion-event handler.""" # Minimum distance we to move before we consider a motion has started. motion_threshold = 10 self._motion_buffer.compute_from_start(event) if self._motion_buffer.distance_from_start > motion_threshold: self._motion_buffer.take_new_motion_event(event) self._event_mode = self.MODE_MOTION self.offset = self._old_offset - self._motion_buffer.dy_from_start return False def _on_scroll_event(self, actor, event): """scroll-event handler (mouse's wheel).""" if not self.active: self.active = True return # Do not scroll if there is no need. if self._offset_max < 0: return False if event.direction == clutter.SCROLL_DOWN: self.scroll_down() else: self.scroll_up() self.emit('moving') return False def _motion_timeline_callback(self, timeline, screen): """Code executed when the animation is finished.""" self.offset = -self.content.get_y()
class MotionBufferTest(EntertainerTest): """Test for entertainerlib.gui.widgets.motion_buffer""" def setUp(self): """Set up the test.""" EntertainerTest.setUp(self) self.buffer = MotionBuffer() def tearDown(self): """Clean up after the test.""" EntertainerTest.tearDown(self) def test_create(self): """Test correct MotionBuffer initialization.""" self.assertTrue(isinstance(self.buffer, MotionBuffer)) def test_computations_from_start(self): """Test all values on a 3 events motion, computed from start.""" self.buffer.start(self._create_first_event()) self.buffer.take_new_motion_event(self._create_second_event()) self.buffer.compute_from_start(self._create_third_event()) self.assertEqual(self.buffer.dt_from_start, 2) self.assertEqual(self.buffer.dx_from_start, 10) self.assertEqual(self.buffer.dy_from_start, 10) self.assertAlmostEqual(self.buffer.distance_from_start, 14.142135624) def test_computations_from_last_event(self): """Test all values on a 3 events motion, computed from last event.""" self.buffer.start(self._create_first_event()) self.buffer.take_new_motion_event(self._create_second_event()) self.buffer.compute_from_last_motion_event(self._create_third_event()) self.assertEqual(self.buffer.dt_from_last_motion_event, 1) self.assertEqual(self.buffer.dx_from_last_motion_event, 10) self.assertEqual(self.buffer.dy_from_last_motion_event, 0) self.assertEqual(self.buffer.distance_from_last_motion_event, 10.0) self.assertEqual(self.buffer.speed_x_from_last_motion_event, 10.0) self.assertEqual(self.buffer.speed_y_from_last_motion_event, 0.0) self.assertEqual(self.buffer.speed_from_last_motion_event, 10.0) self.assertAlmostEqual(self.buffer.dt_ema, 0.3333333333) self.assertAlmostEqual(self.buffer.dx_ema, 3.3333333333) self.assertAlmostEqual(self.buffer.dy_ema, 0.0) self.assertAlmostEqual(self.buffer.distance_ema, 3.3333333333) self.assertAlmostEqual(self.buffer.speed_x_ema, 3.3333333333) self.assertAlmostEqual(self.buffer.speed_y_ema, 0.0) self.assertAlmostEqual(self.buffer.speed_ema, 3.3333333333) def _create_first_event(self): """Create a virtual pointer event.""" event = MockPointerEvent() event.x = 100 event.y = 100 event.time = 0 return event def _create_second_event(self): """Create a virtual pointer event.""" event = MockPointerEvent() event.x = 100 event.y = 110 event.time = 1 return event def _create_third_event(self): """Create a virtual pointer event.""" event = MockPointerEvent() event.x = 110 event.y = 110 event.time = 2 return event
class GridMenu(Base, clutter.Group): """ GridMenu widget. A core widget to handle MenuItem in a grid with a cursor. This widget provides all the necessary logic to move items and the cursor. """ __gsignals__ = { 'activated' : ( gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, () ), 'moved' : ( gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, () ), 'selected' : ( gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, () ), } MODE_NONE = 0 MODE_SELECT = 1 MODE_SEEK = 2 def __init__(self, x=0, y=0, item_width=0.2, item_height=0.1): Base.__init__(self) clutter.Group.__init__(self) self.motion_duration = 100 # Default duration of animations ms. self.cursor_below = True # Is the cursor below items? self._active = None self._is_vertical = True self.items = [] # Items dimensions variable: relative, absolute, center self._item_width = item_width self._item_height = item_height self._item_width_abs = self.get_abs_x(item_width) self._item_height_abs = self.get_abs_y(item_height) self._dx = int(self._item_width_abs / 2) self._dy = int(self._item_height_abs / 2) # Default cursor's index. self._selected_index = 0 # Grid dimensions: real, visible. self.items_per_row = 10 self.items_per_col = 10 self._visible_rows = 3 self._visible_cols = 5 # The moving_group is a Clutter group containing all the items. self._moving_group_x = 0 self._moving_group_y = 0 self._moving_group = clutter.Group() self.add(self._moving_group) # The moving_group is translated using a `BehaviourPath`. self._moving_group_timeline = clutter.Timeline(200) moving_group_alpha = clutter.Alpha(self._moving_group_timeline, clutter.EASE_IN_OUT_SINE) moving_group_path = clutter.Path() self._moving_group_behaviour = clutter.BehaviourPath(moving_group_alpha, moving_group_path) self._moving_group_behaviour.apply(self._moving_group) # The cursor is an Actor that can be added and moved on the menu. # The cusor is always located in the visible (clipped) area of the menu. self._cursor_x = 0 self._cursor_y = 0 self._cursor = None self._cursor_timeline = clutter.Timeline(200) cursor_alpha = clutter.Alpha(self._cursor_timeline, clutter.EASE_IN_SINE) cursor_path = clutter.Path() self._cursor_behaviour = clutter.BehaviourPath(cursor_alpha, cursor_path) # A MotionBuffer is used to compute useful information about the # cursor's motion. It's used when moving the cursor with a pointer. self._motion_buffer = MotionBuffer() self._event_mode = self.MODE_NONE self._motion_handler = 0 self._seek_step_x = 0 self._seek_step_y = 0 gobject.timeout_add(200, self._internal_timer_callback) #XXX: Samuel Buffet # This rectangle is used to grab events as it turns out that their # might be a bug in clutter 0.8 or python-clutter 0.8. # It may be avoided with next release of clutter. self._event_rect = clutter.Rectangle() self._event_rect.set_opacity(0) self.add(self._event_rect) self._event_rect.set_reactive(True) self._event_rect.connect('button-press-event', self._on_button_press_event) self._event_rect.connect('button-release-event', self._on_button_release_event) self._event_rect.connect('scroll-event', self._on_scroll_event) self.set_position(self.get_abs_x(x), self.get_abs_y(y)) @property def count(self): """Return the number of items.""" return len(self.items) @property def on_top(self): """Return True if the selected item is currently on the top.""" selected_row = self._index_to_xy(self._selected_index)[1] if selected_row == 0: return True else: return False @property def on_bottom(self): """Return True if the selected item is currently on the bottom.""" selected_row = self._index_to_xy(self._selected_index)[1] if self._is_vertical: end_row = self._index_to_xy(self.count - 1)[1] if selected_row == end_row: return True else: return False else: if selected_row == self.items_per_col - 1: return True else: return False @property def on_left(self): """Return True if the selected item is currently on the left.""" selected_col = self._index_to_xy(self._selected_index)[0] if selected_col == 0: return True else: return False @property def on_right(self): """Return True if the selected item is currently on the right.""" selected_col = self._index_to_xy(self._selected_index)[0] if not self._is_vertical: end_col = self._index_to_xy(self.count - 1)[0] if selected_col == end_col: return True else: return False else: if selected_col == self.items_per_row - 1: return True else: return False @property def selected_item(self): """Return the selected MenuItem.""" if self.count == 0: return None else: return self.items[self._selected_index] @property def selected_userdata(self): """Return userdata of the MenuItem.""" item = self.selected_item if item is None: return None else: return item.userdata def _get_active(self): """Active property getter.""" return self._active def _set_active(self, boolean): """Active property setter.""" if self._active == boolean: return self._active = boolean if boolean: if self._cursor is not None: self._cursor.show() if self.selected_item is not None: self.selected_item.animate_in() self.emit('activated') self.set_opacity(255) else: if self._cursor is not None: self._cursor.hide() if self.selected_item is not None: self.selected_item.animate_out() self.set_opacity(128) active = property(_get_active, _set_active) def _get_horizontal(self): """horizontal property getter.""" return not self._is_vertical def _set_horizontal(self, boolean): """horizontal property setter.""" self._is_vertical = not boolean horizontal = property(_get_horizontal, _set_horizontal) def _get_vertical(self): """vertical property getter.""" return self._is_vertical def _set_vertical(self, boolean): """vertical property setter.""" self._is_vertical = boolean vertical = property(_get_vertical, _set_vertical) def _get_selected_index(self): """selected_index property getter.""" return self._selected_index def _set_selected_index(self, index, duration=None): """selected_index property setter.""" # Xc, Yc : coordinates of the menu's cursor on the array of items. # xc, yc : coordinates of the menu's cursor relative to the menu. # xm, ym : coordinates of the moving_group relative to the menu. # Xc = xc - xm # Yc = yc - ym if self._selected_index == index or \ index < 0 or \ index > self.count - 1 or \ self._moving_group_timeline.is_playing() or \ self._cursor_timeline.is_playing(): return # Start select/unselect animations on both items. self.items[self._selected_index].animate_out() self.items[index].animate_in() # Get the cursor's coordinate on the array. # /!\ Those coordinates are NOT pixels but refer to the array of items. (Xc, Yc) = self._index_to_xy(index) xm = self._moving_group_x ym = self._moving_group_y xc = Xc + xm yc = Yc + ym # If the targeted cursor's position is on the last visible column then # the moving_group is translated by -1 on the x axis and the translation # of the cursor is reduce by 1 to stay on the column before the last # one. This is not done if the last column has been selected. if xc == self.visible_cols - 1 and \ xm > self.visible_cols -self.items_per_row: xc -= 1 xm -= 1 # If the targeted cursor's position is on the first visible column then # the moving_group is translated by +1 on the x axis and the translation # of the cursor is raised by 1 to stay on the column after the first # one. This is not done if the first column has been selected. if xc == 0 and xm < 0: xc += 1 xm += 1 # If the targeted cursor's position is on the last visible row then # the moving_group is translated by -1 on the y axis and the translation # of the cursor is reduce by 1 to stay on the row before the last # one. This is not done if the last row has been selected. if yc == self.visible_rows - 1 and \ ym > self.visible_rows -self.items_per_col: yc -= 1 ym -= 1 # If the targeted cursor's position is on the first visible row then # the moving_group is translated by +1 on the y axis and the translation # of the cursor is raised by 1 to stay on the row after the first # one. This is not done if the last row has been selected. if yc == 0 and ym < 0: yc += 1 ym += 1 if duration is None: duration = self.motion_duration self._move_cursor(xc, yc, duration) self._move_moving_group(xm, ym, duration) self._selected_index = index self.emit('moved') selected_index = property(_get_selected_index, _set_selected_index) def _get_visible_rows(self): """visible_rows property getter.""" return self._visible_rows def _set_visible_rows(self, visible_rows): """visible_rows property setter.""" self._visible_rows = visible_rows self._clip() visible_rows = property(_get_visible_rows, _set_visible_rows) def _get_visible_cols(self): """visible_cols property getter.""" return self._visible_cols def _set_visible_cols(self, visible_cols): """visible_cols property setter.""" self._visible_cols = visible_cols self._clip() visible_cols = property(_get_visible_cols, _set_visible_cols) def _get_cursor(self): """cursor property getter.""" return self._cursor def _set_cursor(self, cursor): """cursor property setter.""" if self._cursor is not None: self.remove(self._cursor) self._cursor = cursor if self._cursor is not None: self.add(self._cursor) if self._active: self._cursor.show() else: self._cursor.hide() if self.cursor_below: self._cursor.lower_bottom() else: self._cursor.raise_top() self._cursor.set_size(int(self._item_width_abs), int(self._item_height_abs)) self._cursor.set_anchor_point(self._dx, self._dy) self._cursor.set_position(self._dx, self._dy) self._cursor_behaviour.apply(self._cursor) cursor = property(_get_cursor, _set_cursor) def _clip(self): """Updates the clipping region.""" self.set_clip(0, 0, self._visible_cols * self._item_width_abs, self._visible_rows * self._item_height_abs) self._event_rect.set_size(self._visible_cols * self._item_width_abs, self._visible_rows * self._item_height_abs) def stop_animation(self): """Stops the timelines driving menu animation.""" self._moving_group_timeline.stop() self._cursor_timeline.stop() def raw_add_item(self, item): """A method to add an item in the menu.""" self._moving_group.add(item) self.items.append(item) (x, y) = self._index_to_xy(self.count - 1) item.move_anchor_point(self._dx, self._dy) item.set_position(x * self._item_width_abs + self._dx, y * self._item_height_abs + self._dy) if self._is_vertical: self.items_per_col = y + 1 else: self.items_per_row = x + 1 if self.cursor_below: item.raise_top() else: item.lower_bottom() def _index_to_xy(self, index): """Return the coordinates of an element associated to its index.""" if self._is_vertical: r = index / float(self.items_per_row) y = int(math.modf(r)[1]) x = int(index - y * self.items_per_row) else: r = index / float(self.items_per_col) x = int(math.modf(r)[1]) y = int(index - x * self.items_per_col) return (x, y) def _move_moving_group(self, x, y, duration): """Moves the moving_group to x, y coordinates.""" if (x, y) == (self._moving_group_x, self._moving_group_y): return path = clutter.Path() path.add_move_to( self._moving_group_x * self._item_width_abs, self._moving_group_y * self._item_height_abs) path.add_line_to( x * self._item_width_abs, y * self._item_height_abs) self._moving_group_behaviour.set_path(path) self._moving_group_x, self._moving_group_y = x, y self._moving_group_timeline.set_duration(duration) self._moving_group_timeline.start() def _move_cursor(self, x, y, duration): """ Moves the cursor to x, y coordinates. The motion is applied to the center of the cursor. """ if (x, y) == (self._cursor_x, self._cursor_y): return path = clutter.Path() path.add_move_to( self._cursor_x * self._item_width_abs + self._dx, self._cursor_y * self._item_height_abs + self._dy) path.add_line_to( x * self._item_width_abs + self._dx, y * self._item_height_abs + self._dy) self._cursor_behaviour.set_path(path) self._cursor_x, self._cursor_y = x, y self._cursor_timeline.set_duration(duration) self._cursor_timeline.start() def up(self): """Move the menu's cursor up changing the selected_index property.""" if not self.on_top: if self._is_vertical: self.selected_index -= self.items_per_row else: self.selected_index -= 1 def down(self): """Move the menu's cursor down changing the selected_index property.""" if not self.on_bottom: if self._is_vertical: self.selected_index += self.items_per_row else: self.selected_index += 1 def right(self): """Move the menu's cursor right changing the selected_index property.""" if not self.on_right: if self._is_vertical: self.selected_index += 1 else: self.selected_index += self.items_per_col def left(self): """Move the menu's cursor left changing the selected_index property.""" if not self.on_left: if self._is_vertical: self.selected_index -= 1 else: self.selected_index -= self.items_per_col def _internal_timer_callback(self): """ This callback is used to move the cursor if the SEEK mode is activated. """ if self._event_mode == self.MODE_SEEK: if self._seek_step_x == 1: self.right() if self._seek_step_x == -1: self.left() if self._seek_step_y == 1: self.down() if self._seek_step_y == -1: self.up() return True def _on_button_press_event(self, actor, event): """button-press-event handler.""" clutter.grab_pointer(self._event_rect) if not self._event_rect.handler_is_connected(self._motion_handler): self._motion_handler = self._event_rect.connect('motion-event', self._on_motion_event) (x_menu, y_menu) = self.get_transformed_position() (x_moving_group, y_moving_group) = self._moving_group.get_position() # Events coordinates are relative to the stage. # So they need to be computed relatively to the moving group. x = event.x - x_menu - x_moving_group y = event.y - y_menu - y_moving_group x_grid = int(x / self._item_width_abs) y_grid = int(y / self._item_height_abs) if self._is_vertical: new_index = y_grid * self.items_per_row + x_grid else: new_index = x_grid * self.items_per_col + y_grid (delta_x, delta_y) = self._index_to_xy(self._selected_index) delta_x -= x_grid delta_y -= y_grid # Correction factor due to the fact that items are not necessary square, # but most probably rectangles. So the distance in the grid coordinates # must be corrected by a factor to have a real distance in pixels on the # screen. correction = float(self._item_width_abs) / float(self._item_height_abs) correction *= correction distance = math.sqrt(delta_x ** 2 * correction + delta_y ** 2) # Computation of the duration of animations, scaling grid steps to ms. duration = int(distance * 50) if self.selected_index == new_index and \ self.active and \ not self._cursor_timeline.is_playing() and \ not self._moving_group_timeline.is_playing(): self._event_mode = self.MODE_SELECT else: self.active = True self._event_mode = self.MODE_NONE self._set_selected_index(new_index, duration) self._motion_buffer.start(event) return False def _on_button_release_event(self, actor, event): """button-release-event handler.""" clutter.ungrab_pointer() if self._event_rect.handler_is_connected(self._motion_handler): self._event_rect.disconnect_by_func(self._on_motion_event) if self._event_mode == self.MODE_SELECT: self.emit('selected') self._event_mode = self.MODE_NONE return True def _on_motion_event(self, actor, event): """motion-event handler""" # threshold in pixels = the minimum distance we have to move before we # consider a motion has started motion_threshold = 20 self._seek_step_x = 0 self._seek_step_y = 0 self._motion_buffer.compute_from_start(event) self._motion_buffer.compute_from_last_motion_event(event) if self._motion_buffer.distance_from_start > motion_threshold: self._event_mode = self.MODE_SEEK self._motion_buffer.take_new_motion_event(event) dx = self._motion_buffer.dx_from_last_motion_event dy = self._motion_buffer.dy_from_last_motion_event if math.fabs(dx) > math.fabs(dy): self._seek_step_x = dx > 0 and 1 or -1 else: self._seek_step_y = dy > 0 and 1 or -1 return False def _on_scroll_event(self, actor, event): """scroll-event handler (mouse's wheel).""" if not self.active: self.active = True return if event.direction == clutter.SCROLL_DOWN: self.down() else: self.up() return False
class MotionBufferTest(EntertainerTest): """Test for entertainerlib.gui.widgets.motion_buffer""" def setUp(self): '''Set up the test.''' EntertainerTest.setUp(self) self.buffer = MotionBuffer() def tearDown(self): '''Clean up after the test.''' EntertainerTest.tearDown(self) def test_create(self): '''Test correct MotionBuffer initialization.''' self.assertTrue(isinstance(self.buffer, MotionBuffer)) def test_computations_from_start(self): '''Test all values on a 3 events motion, computed from start.''' self.buffer.start(self._create_first_event()) self.buffer.take_new_motion_event(self._create_second_event()) self.buffer.compute_from_start(self._create_third_event()) self.assertEqual(self.buffer.dt_from_start, 2) self.assertEqual(self.buffer.dx_from_start, 10) self.assertEqual(self.buffer.dy_from_start, 10) self.assertAlmostEqual(self.buffer.distance_from_start, 14.142135624) def test_computations_from_last_event(self): '''Test all values on a 3 events motion, computed from last event.''' self.buffer.start(self._create_first_event()) self.buffer.take_new_motion_event(self._create_second_event()) self.buffer.compute_from_last_motion_event(self._create_third_event()) self.assertEqual(self.buffer.dt_from_last_motion_event, 1) self.assertEqual(self.buffer.dx_from_last_motion_event, 10) self.assertEqual(self.buffer.dy_from_last_motion_event, 0) self.assertEqual(self.buffer.distance_from_last_motion_event, 10.0) self.assertEqual(self.buffer.speed_x_from_last_motion_event, 10.0) self.assertEqual(self.buffer.speed_y_from_last_motion_event, 0.0) self.assertEqual(self.buffer.speed_from_last_motion_event, 10.0) self.assertAlmostEqual(self.buffer.dt_ema, 0.3333333333) self.assertAlmostEqual(self.buffer.dx_ema, 3.3333333333) self.assertAlmostEqual(self.buffer.dy_ema, 0.0) self.assertAlmostEqual(self.buffer.distance_ema, 3.3333333333) self.assertAlmostEqual(self.buffer.speed_x_ema, 3.3333333333) self.assertAlmostEqual(self.buffer.speed_y_ema, 0.0) self.assertAlmostEqual(self.buffer.speed_ema, 3.3333333333) def _create_first_event(self): '''Create a virtual pointer event.''' event = MockPointerEvent() event.x = 100 event.y = 100 event.time = 0 return event def _create_second_event(self): '''Create a virtual pointer event.''' event = MockPointerEvent() event.x = 100 event.y = 110 event.time = 1 return event def _create_third_event(self): '''Create a virtual pointer event.''' event = MockPointerEvent() event.x = 110 event.y = 110 event.time = 2 return event
class ScrollArea(Base, clutter.Group): """Wrapper of a clutter Group that allows for scrolling. ScrollArea modifies the width of the content and it assumes that the content uses percent modification (read: not default clutter objects).""" __gsignals__ = { 'activated' : ( gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, () ), 'moving' : ( gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, () ), } MODE_SELECTION = 0 MODE_MOTION = 1 MODE_STOP = 2 STEP_SIZE_PERCENT = 0.04 def __init__(self, x, y, width, height, content): Base.__init__(self) clutter.Group.__init__(self) self._motion_buffer = MotionBuffer() self._offset = 0 # Drives the content motion. self._offset_max = 0 # Maximum value of offset (equal on bottom). self._old_offset = 0 # Stores the old value of offset on motions. self._motion_handler = 0 self._active = None self.step_size = self.get_abs_y(self.STEP_SIZE_PERCENT) # Allowed area for the widget's scrolling content. self.area_width = self.get_abs_x(width) self.area_height = self.get_abs_y(height) # Create content position indicator self.indicator = ListIndicator(3 * width / 4, height, 0.2, 0.045, ListIndicator.VERTICAL) self.indicator.hide_position() self.indicator.set_maximum(2) self.add(self.indicator) # A clipped Group to receive the content. self._fixed_group = clutter.Group() self._fixed_group.set_clip(0, 0, self.area_width, self.area_height) self.add(self._fixed_group) self.content = None self._motion_timeline = clutter.Timeline(500) self._motion_timeline.connect('completed', self._motion_timeline_callback, None) self._motion_alpha = clutter.Alpha(self._motion_timeline, clutter.EASE_OUT_SINE) self._motion_behaviour = LoopedPathBehaviour(self._motion_alpha) self.set_content(content) self.active = None # Preparation to pointer events handling. self.set_reactive(True) self.connect('button-press-event', self._on_button_press_event) self.connect('button-release-event', self._on_button_release_event) self.connect('scroll-event', self._on_scroll_event) self.set_position(self.get_abs_x(x), self.get_abs_y(y)) @property def on_top(self): """True if we're on top.""" return self._offset == 0 @property def on_bottom(self): """True if we're on bottom.""" return self._offset == self._offset_max def _get_active(self): """Active property getter.""" return self._active def _set_active(self, boolean): """Active property setter.""" if self._active == boolean: return self._active = boolean if boolean: # Show indicator if there is need for scrolling. if self._offset_max >= 0: self.indicator.show() self.set_opacity(255) self.emit('activated') else: self.indicator.hide() self.set_opacity(128) active = property(_get_active, _set_active) def _get_offset(self): """Get current offset value.""" return self._offset def _set_offset(self, integer): """Set current offset value.""" if self._offset == integer: return self._offset = integer if self._offset < 0: self._offset = 0 elif self._offset > self._offset_max: self._offset = self._offset_max self.content.set_position(0, - self._offset) # Indicator updates. if self.on_top: self.indicator.set_current(1) elif self.on_bottom: self.indicator.set_current(2) offset = property(_get_offset, _set_offset) def set_content(self, content): """Set content into scroll area.""" if self.content is not None: self._fixed_group.remove(self.content) self._motion_behaviour.remove(self.content) self.content = content self._fixed_group.add(content) self._offset_max = self.content.get_height() - self.area_height self._motion_behaviour.apply(self.content) def stop_animation(self): """Stops the timeline driving animation.""" self._motion_timeline.stop() def scroll_to_top(self): """Scroll content back to top.""" self.offset = 0 def scroll_to_bottom(self): """Scroll content as much as possible.""" self.offset = self._offset_max def scroll_up(self): """Scroll up by one step size.""" self.offset -= self.step_size def scroll_down(self): """Scroll down by one step size.""" self.offset += self.step_size def scroll_page_up(self): """Scroll up by one page. Page is a scroll area height.""" self.offset -= self.area_height def scroll_page_down(self): self.offset += self.area_height def _update_motion_behaviour(self, target): """Preparation of looped behaviour applied to the content.""" self._motion_behaviour.start_knot = (0.0, -self.offset) self._motion_behaviour.end_knot = (0.0, -target) self._motion_behaviour.start_index = 0.0 # Need to set the end index to 0.9999. Indeed the LoopedPathBehaviour # uses an index in [0, 1[. So index = 1 is equivalent to index = 0, the # Actor will the be placed on the start_knot. self._motion_behaviour.end_index = 0.9999 def _on_button_press_event(self, actor, event): """button-press-event handler.""" clutter.grab_pointer(self) if not self.handler_is_connected(self._motion_handler): self._motion_handler = self.connect('motion-event', self._on_motion_event) if self._motion_timeline.is_playing(): # A click with an animation pending should stop the animation. self._motion_timeline.stop() # Go to MODE_STOP to handle correctly next button-release event. self._event_mode = self.MODE_STOP self.offset = -self.content.get_y() else: # No animation pending so we're going to do nothing or to move # all the content. self._old_offset = self.offset self._motion_buffer.start(event) self._event_mode = self.MODE_SELECTION return False def _on_button_release_event(self, actor, event): """button-release-event handler.""" clutter.ungrab_pointer() if self.handler_is_connected(self._motion_handler): self.disconnect_by_func(self._on_motion_event) self._motion_buffer.compute_from_last_motion_event(event) if not self.active: self.active = True return if self._event_mode == self.MODE_MOTION: speed = self._motion_buffer.speed_y_from_last_motion_event # Calculation of the new target according to vertical speed. target = self.offset - speed * 200 if target < 0: target = 0 elif target > self._offset_max: target = self._offset_max self._update_motion_behaviour(target) self._motion_timeline.start() return False def _on_motion_event(self, actor, event): """motion-event handler.""" # Minimum distance we to move before we consider a motion has started. motion_threshold = 10 self._motion_buffer.compute_from_start(event) if self._motion_buffer.distance_from_start > motion_threshold: self._motion_buffer.take_new_motion_event(event) self._event_mode = self.MODE_MOTION self.offset = self._old_offset - self._motion_buffer.dy_from_start return False def _on_scroll_event(self, actor, event): """scroll-event handler (mouse's wheel).""" if not self.active: self.active = True return # Do not scroll if there is no need. if self._offset_max < 0: return False if event.direction == clutter.SCROLL_DOWN: self.scroll_down() else: self.scroll_up() self.emit('moving') return False def _motion_timeline_callback(self, timeline, screen): """Code executed when the animation is finished.""" self.offset = -self.content.get_y()
class ScrollMenu(clutter.Group, object): """Menu widget that contains text items.""" __gtype_name__ = "ScrollMenu" __gsignals__ = { "activated": (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, ()), "selected": (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, ()), "moved": (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, ()), } MODE_SELECTION = 0 MODE_MOTION = 1 MODE_STOP = 2 def __init__(self, item_gap, item_height, font_size, color_name): clutter.Group.__init__(self) self._motion_buffer = MotionBuffer() self._items = [] self._item_gap = item_gap self._item_height = item_height self._item_font_size = font_size self._item_color_name = color_name self._selected_index = 1 self._visible_items = 5 self._event_mode = -1 self._animation_progression = 0 self._animation_start_index = 1 self._animation_end_index = 1 self._active = False self._motion_handler = 0 self._timeline = clutter.Timeline(300) self._alpha = clutter.Alpha(self._timeline, clutter.EASE_IN_OUT_SINE) # preparation to pointer events handling self.set_reactive(True) self.connect("scroll-event", self._on_scroll_event) self.connect("button-press-event", self._on_button_press_event) self.connect("button-release-event", self._on_button_release_event) def refresh(self): """Refresh the menu: clip area dimensions and items positions""" self._set_selected_index(self._selected_index, 1) self._set_visible_items(self._visible_items) def add_item(self, text, name): """Creation of a new MenuItem and addition to the ScrollMenu""" item = ScrollMenuItem(self._alpha, text, self._item_height, self._item_font_size, self._item_color_name) item.set_name(name) item.connect("notify::y", self._update_item_opacity) self.add(item) self._items.append(item) self._update_item_opacity(item) def remove_item(self, name): """Remove an item from the menu""" index = self.get_index(name) if index != -1: # if item was found, we remove it from the item list, from the # group and finally we delete it. item = self._items[index] self._items.remove(item) self.remove(item) del item def _get_active(self): """Active property getter""" return self._active def _set_active(self, boolean): """Active property setter""" if self._active == boolean: return self._active = boolean if boolean: self.set_opacity(255) self.emit("activated") else: self.set_opacity(128) active = property(_get_active, _set_active) def stop_animation(self): """Stops the timeline driving menu animation.""" self._timeline.stop() def _update_behaviours(self, target): """Preparation of behaviours applied to menu items before animation""" items_len = len(self._items) step = 1.0 / items_len step_pix = self._item_gap + self._item_height middle_index = int(self._visible_items / 2) + 1 for x, item in enumerate(self._items): item.behaviour.start_index = (x + middle_index - self._selected_index) * step item.behaviour.end_index = (x + middle_index - target) * step item.behaviour.start_knot = (0.0, -step_pix) item.behaviour.end_knot = (0.0, (items_len - 1.0) * step_pix) def _display_items_at_target(self, target): """Menu is displayed for a particular targeted index value""" step = 1.0 / len(self._items) middle_index = int(self._visible_items / 2) + 1 for x, item in enumerate(self._items): raw_index = (x + middle_index - target) * step if raw_index >= 0: index = math.modf(raw_index)[0] else: index = 1 + math.modf(raw_index)[0] # Calculation of new coordinates xx = index * (item.behaviour.end_knot[0] - item.behaviour.start_knot[0]) + item.behaviour.start_knot[0] yy = index * (item.behaviour.end_knot[1] - item.behaviour.start_knot[1]) + item.behaviour.start_knot[1] item.set_position(int(xx), int(yy)) def _get_visible_items(self): """visible_items property getter""" return self._visible_items def _set_visible_items(self, visible_items): """visible_items property setter""" self._visible_items = visible_items height = visible_items * self._item_height + (visible_items - 1) * self._item_gap self.set_clip(0, 0, self.get_width(), height) visible_items = property(_get_visible_items, _set_visible_items) def _get_selected_index(self): """selected_index property getter""" return self._selected_index def _set_selected_index(self, selected_index, duration=300): """selected_index property setter""" if not self._timeline.is_playing(): items_len = len(self._items) self._update_behaviours(selected_index) # those 2 variables are used if we want to stop the timeline # we use them + timeline progression to calculate the current index # when (if) we stop self._animation_start_index = self._selected_index self._animation_end_index = selected_index # selected_index can be any desired value but in the end, # we have to rescale it to be between 0 and (items_len-1) if selected_index >= 0: self._selected_index = selected_index - math.modf(selected_index / items_len)[1] * items_len else: self._selected_index = ( selected_index + (math.modf(-(selected_index + 1) / items_len)[1] + 1) * items_len ) self._timeline.set_duration(duration) self._timeline.start() self.emit("moved") selected_index = property(_get_selected_index, _set_selected_index) def get_selected(self): """Get currently selected menuitem""" return self._items[int(self._selected_index)] def get_index(self, text): """Returns index of label with the text as passed or -1 if not found""" for item in self._items: if item.get_name() == text: return self._items.index(item) return -1 def scroll_by(self, step, duration=300): """Generic scroll of menu items""" self._set_selected_index(self._selected_index + step, duration) def scroll_up(self, duration=300): """All menu items are scrolled up""" self.scroll_by(-1, duration) def scroll_down(self, duration=300): """All menu items are scrolled down""" self.scroll_by(1, duration) def get_opacity_for_y(self, y): """Calculation of actor's opacity as a function of its y coordinates""" opacity_first_item = 40 opacity_selected_item = 255 middle = int(self._visible_items / 2) y_medium_item = middle * (self._item_height + self._item_gap) a = float(opacity_selected_item - opacity_first_item) a /= float(y_medium_item) if y <= y_medium_item: opacity = y * a + opacity_first_item else: opacity = opacity_selected_item * 2 - opacity_first_item - a * y if opacity < 0: opacity = 0 return int(opacity) def _update_item_opacity(self, item, stage=None): """Set opacity to actors when they are moving. Opacity is f(y)""" opacity = self.get_opacity_for_y(item.get_y()) item.set_opacity(opacity) def _on_button_press_event(self, actor, event): """button-press-event handler""" clutter.grab_pointer(self) if not self.handler_is_connected(self._motion_handler): self._motion_handler = self.connect("motion-event", self._on_motion_event) if self._timeline.is_playing(): # before we stop the timeline, store its progression self._animation_progression = self._timeline.get_progress() # A click with an animation pending should stop the animation self._timeline.stop() # go to MODE_STOP to handle correctly next button-release event self._event_mode = self.MODE_STOP else: # no animation pending so we're going to do either a menu_item # selection or a menu motion. This will be decided later, right now # we just take a snapshot of this button-press-event as a start. self._motion_buffer.start(event) self._event_mode = self.MODE_SELECTION return False def _on_button_release_event(self, actor, event): """button-release-event handler""" items_len = len(self._items) clutter.ungrab_pointer() if self.handler_is_connected(self._motion_handler): self.disconnect_by_func(self._on_motion_event) self._motion_buffer.compute_from_last_motion_event(event) if not self.active: self.active = True return y = event.y - self.get_y() if self._event_mode == self.MODE_SELECTION: # if we are in MODE_SELECTION it means that we want to select # the menu item bellow the pointer for index, item in enumerate(self._items): item_y = item.get_y() item_h = item.get_height() if (y >= item_y) and (y <= (item_y + item_h)): delta1 = index - self._selected_index delta2 = index - self._selected_index + items_len delta3 = index - self._selected_index - items_len delta = 99999 for i in [delta1, delta2, delta3]: if math.fabs(i) < math.fabs(delta): delta = i self.scroll_by(delta) # if delta = 0 it means we've clicked on the selected item if delta == 0: self.emit("selected") elif self._event_mode == self.MODE_MOTION: speed = self._motion_buffer.speed_y_from_last_motion_event target = ( self._selected_index - self._motion_buffer.dy_from_start / self._items[0].behaviour.path_length * items_len ) new_index = int(target - 5 * speed) self._selected_index = target self._set_selected_index(new_index, 1000) else: # If we have stopped the pending animation. Now we have to do # a small other one to select the closest menu-item current_index = ( self._animation_start_index + (self._animation_end_index - self._animation_start_index) * self._animation_progression ) self._selected_index = current_index target_index = int(current_index) self._set_selected_index(target_index, 1000) return False def _on_motion_event(self, actor, event): """motion-event handler""" # threshold in pixels = the minimum distance we have to move before we # consider a motion has started motion_threshold = 10 self._motion_buffer.compute_from_start(event) if self._motion_buffer.distance_from_start > motion_threshold: self._motion_buffer.take_new_motion_event(event) self._event_mode = self.MODE_MOTION target = self._selected_index - self._motion_buffer.dy_from_start / self._items[ 0 ].behaviour.path_length * len(self._items) self._display_items_at_target(target) return False def _on_scroll_event(self, actor, event): """scroll-event handler (mouse's wheel)""" self.active = True if event.direction == clutter.SCROLL_DOWN: self.scroll_down(duration=150) else: self.scroll_up(duration=150) return False