Package Gnumed :: Package timelinelib :: Package wxgui :: Package components :: Module categorytree |
[frames] | no frames]

# Source Code for Module Gnumed.timelinelib.wxgui.components.categorytree

      1  # Copyright (C) 2009, 2010, 2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018  Rickard Lindberg, Roger Lindberg
      2  #
      3  # This file is part of Timeline.
      4  #
      5  # Timeline is free software: you can redistribute it and/or modify
      6  # it under the terms of the GNU General Public License as published by
      7  # the Free Software Foundation, either version 3 of the License, or
      8  # (at your option) any later version.
      9  #
     10  # Timeline is distributed in the hope that it will be useful,
     11  # but WITHOUT ANY WARRANTY; without even the implied warranty of
     12  # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
     13  # GNU General Public License for more details.
     14  #
     15  # You should have received a copy of the GNU General Public License
     16  # along with Timeline.  If not, see <http://www.gnu.org/licenses/>.
     17
     18
     19  import wx
     20
     21  from timelinelib.canvas.data.exceptions import TimelineIOError
     22  from timelinelib.canvas.drawing.utils import darken_color
     23  from timelinelib.db.utils import safe_locking
     24  from timelinelib import DEBUG_ENABLED
     25  from timelinelib.monitoring import Monitoring
     26  from timelinelib.general.observer import Observable
     27  from timelinelib.repositories.categories import CategoriesFacade
     28  from timelinelib.wxgui.components.font import Font
     29  from timelinelib.wxgui.dialogs.editcategory.view import EditCategoryDialog
     30  import timelinelib.wxgui.utils as gui_utils
     31
     32
     33 -class CustomCategoryTree(wx.ScrolledWindow):
     34
     35 -    def __init__(self, parent, name=None, size=(100, 100)):
     36          self.monitoring = Monitoring()
     37          wx.ScrolledWindow.__init__(self, parent, size=size)
     38          self.parent = parent
     39          self._create_context_menu()
     40          self.Bind(wx.EVT_PAINT, self._on_paint)
     41          self.Bind(wx.EVT_SIZE, self._on_size)
     42          self.Bind(wx.EVT_LEFT_DOWN, self._on_left_down)
     43          self.Bind(wx.EVT_RIGHT_DOWN, self._on_right_down)
     44          self.Bind(wx.EVT_LEFT_DCLICK, self._on_left_doubleclick)
     45          self.model = CustomCategoryTreeModel()
     46          self.model.listen_for_any(self._redraw)
     47          self.renderer = CustomCategoryTreeRenderer(self, self.model)
     48          self.set_no_timeline_view()
     49          self._size_to_model()
     50          self._draw_bitmap()
     51
     52 -    def set_no_timeline_view(self):
     53          self.db = None
     54          self.view_properties = None
     55          self.model.set_categories(None)
     56
     57 -    def set_timeline_view(self, db, view_properties):
     58          self.db = db
     59          self.view_properties = view_properties
     60          self.model.set_categories(CategoriesFacade(db, view_properties))
     61
     62 -    def check_categories(self, categories):
     63          self.view_properties.set_categories_visible(categories)
     64
     65 -    def uncheck_categories(self, categories):
     66          self.view_properties.set_categories_visible(categories, False)
     67
     68 -    def _has_timeline_view(self):
     69          return self.db is not None and self.view_properties is not None
     70
     71 -    def _on_paint(self, event):
     72          wx.BufferedPaintDC(self, self.buffer_image, wx.BUFFER_VIRTUAL_AREA)
     73
     74 -    def _on_size(self, event):
     75          self._size_to_model()
     76
     77 -    def _on_left_doubleclick(self, event):
     78          def edit_function():
     79              self._edit_category()
     80          safe_locking(self.parent, edit_function)
     81
     82 -    def _on_left_down(self, event):
     83          self.SetFocus()
     84          self._store_hit_info(event)
     85          hit_category = self.last_hit_info.get_category()
     86          if self.last_hit_info.is_on_arrow():
     87              self.model.toggle_expandedness(hit_category)
     88          elif self.last_hit_info.is_on_checkbox():
     89              self.view_properties.toggle_category_visibility(hit_category)
     90
     91 -    def _on_right_down(self, event):
     92          def edit_function():
     93              self._store_hit_info(event)
     94              for (menu_item, should_be_enabled_fn) in self.context_menu_items:
     95                  menu_item.Enable(should_be_enabled_fn(self.last_hit_info))
     96              self.PopupMenu(self.context_menu)
     97          safe_locking(self.parent, edit_function)
     98
    101
    104
    109
    113
    117
    121
    125
    126 -    def _on_menu_check_parents_for_checked_children(self, e):
    127          self.view_properties.set_categories_visible(
    128              self.last_hit_info.get_parents_for_checked_childs())
    129
    133
    137
    141
    145
    146 -    def _edit_category(self):
    147          hit_category = self.last_hit_info.get_category()
    148          if hit_category:
    149              edit_category(self, self.db, hit_category)
    150
    151 -    def _store_hit_info(self, event):
    152          (x, y) = self.CalcUnscrolledPosition(event.GetX(), event.GetY())
    153          self.last_hit_info = self.model.hit(x, y)
    154
    155 -    def _redraw(self):
    156          self.SetVirtualSize((-1, self.model.ITEM_HEIGHT_PX * len(self.model.items)))
    157          self.SetScrollRate(0, self.model.ITEM_HEIGHT_PX / 2)
    158          self._draw_bitmap()
    159          self.Refresh()
    160          self.Update()
    161
    162 -    def _draw_bitmap(self):
    163          width, height = self.GetVirtualSizeTuple()
    164          self.buffer_image = wx.EmptyBitmap(width, height)
    165          memdc = wx.BufferedDC(None, self.buffer_image)
    166          memdc.SetBackground(wx.Brush(self.GetBackgroundColour(), wx.PENSTYLE_SOLID))
    167          memdc.Clear()
    168          memdc.BeginDrawing()
    169          self.monitoring.timer_start()
    170          self.renderer.render(memdc)
    171          self.monitoring.timer_end()
    172          if DEBUG_ENABLED:
    173              (width, height) = self.GetSizeTuple()
    174              redraw_time = self.monitoring.timer_elapsed_ms
    175              self.monitoring.count_category_redraw()
    176              memdc.SetTextForeground((255, 0, 0))
    177              memdc.SetFont(Font(10, weight=wx.FONTWEIGHT_BOLD))
    178              memdc.DrawText("Redraw count: %d" % self.monitoring._category_redraw_count, 10, height - 35)
    179              memdc.DrawText("Last redraw time: %.3f ms" % redraw_time, 10, height - 20)
    180          memdc.EndDrawing()
    181          del memdc
    182
    183 -    def _size_to_model(self):
    184          (view_width, view_height) = self.GetVirtualSizeTuple()
    185          self.model.set_view_size(view_width, view_height)
    186
    194          self.context_menu_items = []
    195          self.context_menu = wx.Menu()
    196          add_item(
    197              _("Edit..."),
    198              self._on_menu_edit,
    199              lambda hit_info: hit_info.has_category())
    200          add_item(
    201              _("Add..."),
    202              self._on_menu_add,
    203              lambda hit_info: self._has_timeline_view())
    204          add_item(
    205              _("Delete"),
    206              self._on_menu_delete,
    207              lambda hit_info: hit_info.has_category())
    208          self.context_menu.AppendSeparator()
    209          add_item(
    210              _("Check All"),
    211              self._on_menu_check_all,
    212              lambda hit_info: self._has_timeline_view())
    213          add_item(
    214              _("Check children"),
    215              self._on_menu_check_children,
    216              lambda hit_info: hit_info.has_category())
    217          add_item(
    218              _("Check all children"),
    219              self._on_menu_check_all_children,
    220              lambda hit_info: hit_info.has_category())
    221          add_item(
    222              _("Check all parents"),
    223              self._on_menu_check_parents,
    224              lambda hit_info: hit_info.has_category())
    225          add_item(
    226              _("Check parents for checked children"),
    227              self._on_menu_check_parents_for_checked_children,
    228              lambda hit_info: self._has_timeline_view())
    229          self.context_menu.AppendSeparator()
    230          add_item(
    231              _("Uncheck All"),
    232              self._on_menu_uncheck_all,
    233              lambda hit_info: self._has_timeline_view())
    234          add_item(
    235              _("Uncheck children"),
    236              self._on_menu_uncheck_children,
    237              lambda hit_info: hit_info.has_category())
    238          add_item(
    239              _("Uncheck all children"),
    240              self._on_menu_uncheck_all_children,
    241              lambda hit_info: hit_info.has_category())
    242          add_item(
    243              _("Uncheck all parents"),
    244              self._on_menu_uncheck_parents,
    245              lambda hit_info: hit_info.has_category())
    246
    247
    248 -class CustomCategoryTreeRenderer(object):
    249
    250      INNER_PADDING = 2
    251      TRIANGLE_SIZE = 8
    252
    253 -    def __init__(self, window, model):
    254          self.window = window
    255          self.model = model
    256
    257 -    def render(self, dc):
    258          self.dc = dc
    259          self._render_items(self.model.items)
    260          del self.dc
    261
    262 -    def _render_items(self, items):
    263          self.dc.SetFont(wx.SystemSettings.GetFont(wx.SYS_DEFAULT_GUI_FONT))
    264          for item in items:
    265              self._render_item(item)
    266
    267 -    def _render_item(self, item):
    268          if item["has_children"]:
    269              self._render_arrow(item)
    270          self._render_checkbox(item)
    271          self._render_name(item)
    272          self._render_color_box(item)
    273
    274 -    def _render_arrow(self, item):
    275          self.dc.SetBrush(wx.Brush(wx.Colour(100, 100, 100), wx.PENSTYLE_SOLID))
    276          self.dc.SetPen(wx.Pen(wx.Colour(100, 100, 100), 0, wx.PENSTYLE_SOLID))
    277          offset = self.TRIANGLE_SIZE / 2
    278          center_x = item["x"] + 2 * self.INNER_PADDING + offset
    279          center_y = item["y"] + self.model.ITEM_HEIGHT_PX / 2 - 1
    280          if item["expanded"]:
    281              open_polygon = [
    282                  wx.Point(center_x - offset, center_y - offset),
    283                  wx.Point(center_x + offset, center_y - offset),
    284                  wx.Point(center_x, center_y + offset),
    285              ]
    286              self.dc.DrawPolygon(open_polygon)
    287          else:
    288              closed_polygon = [
    289                  wx.Point(center_x - offset, center_y - offset),
    290                  wx.Point(center_x - offset, center_y + offset),
    291                  wx.Point(center_x + offset, center_y),
    292              ]
    293              self.dc.DrawPolygon(closed_polygon)
    294
    295 -    def _render_name(self, item):
    296          x = item["x"] + self.TRIANGLE_SIZE + 4 * self.INNER_PADDING + 20
    297          (_, h) = self.dc.GetTextExtent(item["name"])
    298          if item["actually_visible"]:
    299              self.dc.SetTextForeground(self.window.GetForegroundColour())
    300          else:
    301              self.dc.SetTextForeground((150, 150, 150))
    302          self.dc.DrawText(item["name"],
    303                           x + self.INNER_PADDING,
    304                           item["y"] + (self.model.ITEM_HEIGHT_PX - h) / 2)
    305
    306 -    def _render_checkbox(self, item):
    307          (w, h) = (17, 17)
    308          bouning_rect = wx.Rect(item["x"] + self.model.INDENT_PX,
    309                                 item["y"] + (self.model.ITEM_HEIGHT_PX - h) / 2,
    310                                 w,
    311                                 h)
    312          if item["visible"]:
    313              flag = wx.CONTROL_CHECKED
    314          else:
    315              flag = 0
    316          renderer = wx.RendererNative.Get()
    317          renderer.DrawCheckBox(self.window, self.dc, bouning_rect, flag)
    318
    319 -    def _render_color_box(self, item):
    320          color = item.get("color", None)
    321          self.dc.SetBrush(wx.Brush(color, wx.PENSTYLE_SOLID))
    322          self.dc.SetPen(wx.Pen(darken_color(color), 1, wx.PENSTYLE_SOLID))
    323          (w, h) = (16, 16)
    324          self.dc.DrawRectangle(
    325              item["x"] + item["width"] - w - self.INNER_PADDING,
    326              item["y"] + self.model.ITEM_HEIGHT_PX / 2 - h / 2,
    327              w,
    328              h)
    329
    330
    331 -class CustomCategoryTreeModel(Observable):
    332
    333      ITEM_HEIGHT_PX = 22
    334      INDENT_PX = 15
    335
    336 -    def __init__(self):
    337          Observable.__init__(self)
    338          self.view_width = 0
    339          self.view_height = 0
    340          self.categories = None
    341          self.collapsed_category_ids = []
    342          self.items = []
    343
    344 -    def get_items(self):
    345          return self.items
    346
    347 -    def set_view_size(self, view_width, view_height):
    348          self.view_width = view_width
    349          self.view_height = view_height
    350          self._update_items()
    351
    352 -    def set_categories(self, categories):
    353          if self.categories:
    354              self.categories.unlisten(self._update_items)
    355          self.categories = categories
    356          if self.categories:
    357              self.categories.listen_for_any(self._update_items)
    358          self._update_items()
    359
    360 -    def hit(self, x, y):
    361          item = self._item_at(y)
    362          if item:
    363              return HitInfo(self.categories,
    364                             item["category"],
    365                             self._hits_arrow(x, item),
    366                             self._hits_checkbox(x, item))
    367          else:
    368              return HitInfo(self.categories, None, False, False)
    369
    370 -    def toggle_expandedness(self, category):
    371          if category.get_id() in self.collapsed_category_ids:
    372              self.collapsed_category_ids.remove(category.get_id())
    373              self._update_items()
    374          else:
    375              self.collapsed_category_ids.append(category.get_id())
    376              self._update_items()
    377
    378 -    def _item_at(self, y):
    379          index = y // self.ITEM_HEIGHT_PX
    380          if index < len(self.items):
    381              return self.items[index]
    382          else:
    383              return None
    384
    385 -    def _hits_arrow(self, x, item):
    386          return (x > item["x"] and
    387                  x < (item["x"] + self.INDENT_PX))
    388
    389 -    def _hits_checkbox(self, x, item):
    390          return (x > (item["x"] + self.INDENT_PX) and
    391                  x < (item["x"] + 2 * self.INDENT_PX))
    392
    393 -    def _update_items(self):
    394          self.items = []
    395          self.y = 0
    396          self._update_from_tree(self._list_to_tree(self._get_categories()))
    397          self._notify(None)
    398
    399 -    def _get_categories(self):
    400          if self.categories is None:
    401              return []
    402          else:
    403              return self.categories.get_all()
    404
    405 -    def _list_to_tree(self, categories, parent=None):
    406          top = [category for category in categories if (category._get_parent() == parent)]
    407          sorted_top = sorted(top, key=lambda category: category.get_name())
    408          return [(category, self._list_to_tree(categories, category)) for
    409                  category in sorted_top]
    410
    411 -    def _update_from_tree(self, category_tree, indent_level=0):
    412          for (category, child_tree) in category_tree:
    413              expanded = category.get_id() not in self.collapsed_category_ids
    414              self.items.append({
    415                  "id": category.get_id(),
    416                  "name": category.get_name(),
    417                  "color": category.get_color(),
    418                  "visible": self._is_category_visible(category),
    419                  "x": indent_level * self.INDENT_PX,
    420                  "y": self.y,
    421                  "width": self.view_width - indent_level * self.INDENT_PX,
    422                  "expanded": expanded,
    423                  "has_children": len(child_tree) > 0,
    424                  "actually_visible": self._is_event_with_category_visible(category),
    425                  "category": category,
    426              })
    427              self.y += self.ITEM_HEIGHT_PX
    428              if expanded:
    429                  self._update_from_tree(child_tree, indent_level + 1)
    430
    431 -    def _is_category_visible(self, category):
    432          return self.categories.is_visible(category)
    433
    434 -    def _is_event_with_category_visible(self, category):
    435          return self.categories.is_event_with_category_visible(category)
    436
    437
    438 -class HitInfo(object):
    439
    440 -    def __init__(self, categories, category, is_on_arrow, is_on_checkbox):
    441          self._categories = categories
    442          self._category = category
    443          self._is_on_arrow = is_on_arrow
    444          self._is_on_checkbox = is_on_checkbox
    445
    446 -    def has_category(self):
    447          return self._category is not None
    448
    449 -    def get_category(self):
    450          return self._category
    451
    452 -    def get_immediate_children(self):
    453          return self._categories.get_immediate_children(self._category)
    454
    455 -    def get_all_children(self):
    456          return self._categories.get_all_children(self._category)
    457
    458 -    def get_parents(self):
    459          return self._categories.get_parents(self._category)
    460
    461 -    def get_parents_for_checked_childs(self):
    462          return self._categories.get_parents_for_checked_childs()
    463
    464 -    def is_on_arrow(self):
    465          return self._is_on_arrow
    466
    467 -    def is_on_checkbox(self):
    468          return self._is_on_checkbox
    469
    470
    471 -def edit_category(parent_ctrl, db, cat):
    472      dialog = EditCategoryDialog(parent_ctrl, _("Edit Category"), db, cat)
    473      dialog.ShowModal()
    474      dialog.Destroy()
    475
    476
    477 -def add_category(parent_ctrl, db):
    478      dialog = EditCategoryDialog(parent_ctrl, _("Add Category"), db, None)
    479      dialog.ShowModal()
    480      dialog.Destroy()
    481
    482
    483 -def delete_category(parent_ctrl, db, cat):
    484      delete_warning = _("Are you sure you want to "
    485                         "delete category '%s'?") % cat.name
    486      if cat.parent is None:
    487          update_warning = _("Events belonging to '%s' will no longer "
    488                             "belong to a category.") % cat.name
    489      else:
    490          update_warning = _("Events belonging to '%s' will now belong "
    491                             "to '%s'.") % (cat.name, cat.parent.name)
    492      question = "%s\n\n%s" % (delete_warning, update_warning)
    493      if gui_utils._ask_question(question, parent_ctrl) == wx.YES:
    494          db.delete_category(cat)
    495