widget_templates.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454
  1. """Implement common widgets layouts as reusable components"""
  2. import re
  3. from collections import defaultdict
  4. from traitlets import Instance, Bool, Unicode, CUnicode, CaselessStrEnum, Tuple
  5. from traitlets import Integer
  6. from traitlets import HasTraits, TraitError
  7. from traitlets import observe, validate
  8. from .widget import Widget
  9. from .widget_box import GridBox
  10. from .docutils import doc_subst
  11. _doc_snippets = {
  12. 'style_params' : """
  13. grid_gap : str
  14. CSS attribute used to set the gap between the grid cells
  15. justify_content : str, in ['flex-start', 'flex-end', 'center', 'space-between', 'space-around']
  16. CSS attribute used to align widgets vertically
  17. align_items : str, in ['top', 'bottom', 'center', 'flex-start', 'flex-end', 'baseline', 'stretch']
  18. CSS attribute used to align widgets horizontally
  19. width : str
  20. height : str
  21. width and height"""
  22. }
  23. @doc_subst(_doc_snippets)
  24. class LayoutProperties(HasTraits):
  25. """Mixin class for layout templates
  26. This class handles mainly style attributes (height, grid_gap etc.)
  27. Parameters
  28. ----------
  29. {style_params}
  30. Note
  31. ----
  32. This class is only meant to be used in inheritance as mixin with other
  33. classes. It will not work, unless `self.layout` attribute is defined.
  34. """
  35. # style attributes (passed to Layout)
  36. grid_gap = Unicode(
  37. None,
  38. allow_none=True,
  39. help="The grid-gap CSS attribute.")
  40. justify_content = CaselessStrEnum(
  41. ['flex-start', 'flex-end', 'center',
  42. 'space-between', 'space-around'],
  43. allow_none=True,
  44. help="The justify-content CSS attribute.")
  45. align_items = CaselessStrEnum(
  46. ['top', 'bottom',
  47. 'flex-start', 'flex-end', 'center',
  48. 'baseline', 'stretch'],
  49. allow_none=True, help="The align-items CSS attribute.")
  50. width = Unicode(
  51. None,
  52. allow_none=True,
  53. help="The width CSS attribute.")
  54. height = Unicode(
  55. None,
  56. allow_none=True,
  57. help="The width CSS attribute.")
  58. def __init__(self, **kwargs):
  59. super(LayoutProperties, self).__init__(**kwargs)
  60. self._property_rewrite = defaultdict(dict)
  61. self._property_rewrite['align_items'] = {'top': 'flex-start',
  62. 'bottom': 'flex-end'}
  63. self._copy_layout_props()
  64. self._set_observers()
  65. def _delegate_to_layout(self, change):
  66. "delegate the trait types to their counterparts in self.layout"
  67. value, name = change['new'], change['name']
  68. value = self._property_rewrite[name].get(value, value)
  69. setattr(self.layout, name, value) # pylint: disable=no-member
  70. def _set_observers(self):
  71. "set observers on all layout properties defined in this class"
  72. _props = LayoutProperties.class_trait_names()
  73. self.observe(self._delegate_to_layout, _props)
  74. def _copy_layout_props(self):
  75. _props = LayoutProperties.class_trait_names()
  76. for prop in _props:
  77. value = getattr(self, prop)
  78. if value:
  79. value = self._property_rewrite[prop].get(value, value)
  80. setattr(self.layout, prop, value) #pylint: disable=no-member
  81. @doc_subst(_doc_snippets)
  82. class AppLayout(GridBox, LayoutProperties):
  83. """ Define an application like layout of widgets.
  84. Parameters
  85. ----------
  86. header: instance of Widget
  87. left_sidebar: instance of Widget
  88. center: instance of Widget
  89. right_sidebar: instance of Widget
  90. footer: instance of Widget
  91. widgets to fill the positions in the layout
  92. merge: bool
  93. flag to say whether the empty positions should be automatically merged
  94. pane_widths: list of numbers/strings
  95. the fraction of the total layout width each of the central panes should occupy
  96. (left_sidebar,
  97. center, right_sidebar)
  98. pane_heights: list of numbers/strings
  99. the fraction of the width the vertical space that the panes should occupy
  100. (left_sidebar, center, right_sidebar)
  101. {style_params}
  102. Examples
  103. --------
  104. """
  105. # widget positions
  106. header = Instance(Widget, allow_none=True)
  107. footer = Instance(Widget, allow_none=True)
  108. left_sidebar = Instance(Widget, allow_none=True)
  109. right_sidebar = Instance(Widget, allow_none=True)
  110. center = Instance(Widget, allow_none=True)
  111. # extra args
  112. pane_widths = Tuple(CUnicode(), CUnicode(), CUnicode(),
  113. default_value=['1fr', '2fr', '1fr'])
  114. pane_heights = Tuple(CUnicode(), CUnicode(), CUnicode(),
  115. default_value=['1fr', '3fr', '1fr'])
  116. merge = Bool(default_value=True)
  117. def __init__(self, **kwargs):
  118. super(AppLayout, self).__init__(**kwargs)
  119. self._update_layout()
  120. @staticmethod
  121. def _size_to_css(size):
  122. if re.match(r'\d+\.?\d*(px|fr|%)$', size):
  123. return size
  124. if re.match(r'\d+\.?\d*$', size):
  125. return size + 'fr'
  126. raise TypeError("the pane sizes must be in one of the following formats: "
  127. "'10px', '10fr', 10 (will be converted to '10fr')."
  128. "Got '{}'".format(size))
  129. def _convert_sizes(self, size_list):
  130. return list(map(self._size_to_css, size_list))
  131. def _update_layout(self):
  132. grid_template_areas = [["header", "header", "header"],
  133. ["left-sidebar", "center", "right-sidebar"],
  134. ["footer", "footer", "footer"]]
  135. grid_template_columns = self._convert_sizes(self.pane_widths)
  136. grid_template_rows = self._convert_sizes(self.pane_heights)
  137. all_children = {'header': self.header,
  138. 'footer': self.footer,
  139. 'left-sidebar': self.left_sidebar,
  140. 'right-sidebar': self.right_sidebar,
  141. 'center': self.center}
  142. children = {position : child
  143. for position, child in all_children.items()
  144. if child is not None}
  145. if not children:
  146. return
  147. for position, child in children.items():
  148. child.layout.grid_area = position
  149. if self.merge:
  150. if len(children) == 1:
  151. position = list(children.keys())[0]
  152. grid_template_areas = [[position, position, position],
  153. [position, position, position],
  154. [position, position, position]]
  155. else:
  156. if self.center is None:
  157. for row in grid_template_areas:
  158. del row[1]
  159. del grid_template_columns[1]
  160. if self.left_sidebar is None:
  161. grid_template_areas[1][0] = grid_template_areas[1][1]
  162. if self.right_sidebar is None:
  163. grid_template_areas[1][-1] = grid_template_areas[1][-2]
  164. if (self.left_sidebar is None and
  165. self.right_sidebar is None and
  166. self.center is None):
  167. grid_template_areas = [['header'], ['footer']]
  168. grid_template_columns = ['1fr']
  169. grid_template_rows = ['1fr', '1fr']
  170. if self.header is None:
  171. del grid_template_areas[0]
  172. del grid_template_rows[0]
  173. if self.footer is None:
  174. del grid_template_areas[-1]
  175. del grid_template_rows[-1]
  176. grid_template_areas_css = "\n".join('"{}"'.format(" ".join(line))
  177. for line in grid_template_areas)
  178. self.layout.grid_template_columns = " ".join(grid_template_columns)
  179. self.layout.grid_template_rows = " ".join(grid_template_rows)
  180. self.layout.grid_template_areas = grid_template_areas_css
  181. self.children = tuple(children.values())
  182. @observe("footer", "header", "center", "left_sidebar", "right_sidebar", "merge",
  183. "pane_widths", "pane_heights")
  184. def _child_changed(self, change): #pylint: disable=unused-argument
  185. self._update_layout()
  186. @doc_subst(_doc_snippets)
  187. class GridspecLayout(GridBox, LayoutProperties):
  188. """ Define a N by M grid layout
  189. Parameters
  190. ----------
  191. n_rows : int
  192. number of rows in the grid
  193. n_columns : int
  194. number of columns in the grid
  195. {style_params}
  196. Examples
  197. --------
  198. >>> from ipywidgets import GridspecLayout, Button, Layout
  199. >>> layout = GridspecLayout(n_rows=4, n_columns=2, height='200px')
  200. >>> layout[:3, 0] = Button(layout=Layout(height='auto', width='auto'))
  201. >>> layout[1:, 1] = Button(layout=Layout(height='auto', width='auto'))
  202. >>> layout[-1, 0] = Button(layout=Layout(height='auto', width='auto'))
  203. >>> layout[0, 1] = Button(layout=Layout(height='auto', width='auto'))
  204. >>> layout
  205. """
  206. n_rows = Integer()
  207. n_columns = Integer()
  208. def __init__(self, n_rows=None, n_columns=None, **kwargs):
  209. super(GridspecLayout, self).__init__(**kwargs)
  210. self.n_rows = n_rows
  211. self.n_columns = n_columns
  212. self._grid_template_areas = [['.'] * self.n_columns for i in range(self.n_rows)]
  213. self._grid_template_rows = 'repeat(%d, 1fr)' % (self.n_rows,)
  214. self._grid_template_columns = 'repeat(%d, 1fr)' % (self.n_columns,)
  215. self._children = {}
  216. self._id_count = 0
  217. @validate('n_rows', 'n_columns')
  218. def _validate_integer(self, proposal):
  219. if proposal['value'] > 0:
  220. return proposal['value']
  221. raise TraitError('n_rows and n_columns must be positive integer')
  222. def _get_indices_from_slice(self, row, column):
  223. "convert a two-dimensional slice to a list of rows and column indices"
  224. if isinstance(row, slice):
  225. start, stop, stride = row.indices(self.n_rows)
  226. rows = range(start, stop, stride)
  227. else:
  228. rows = [row]
  229. if isinstance(column, slice):
  230. start, stop, stride = column.indices(self.n_columns)
  231. columns = range(start, stop, stride)
  232. else:
  233. columns = [column]
  234. return rows, columns
  235. def __setitem__(self, key, value):
  236. row, column = key
  237. self._id_count += 1
  238. obj_id = 'widget%03d' % self._id_count
  239. value.layout.grid_area = obj_id
  240. rows, columns = self._get_indices_from_slice(row, column)
  241. for row in rows:
  242. for column in columns:
  243. current_value = self._grid_template_areas[row][column]
  244. if current_value != '.' and current_value in self._children:
  245. del self._children[current_value]
  246. self._grid_template_areas[row][column] = obj_id
  247. self._children[obj_id] = value
  248. self._update_layout()
  249. def __getitem__(self, key):
  250. rows, columns = self._get_indices_from_slice(*key)
  251. obj_id = None
  252. for row in rows:
  253. for column in columns:
  254. new_obj_id = self._grid_template_areas[row][column]
  255. obj_id = obj_id or new_obj_id
  256. if obj_id != new_obj_id:
  257. raise TypeError('The slice spans several widgets, but '
  258. 'only a single widget can be retrieved '
  259. 'at a time')
  260. return self._children[obj_id]
  261. def _update_layout(self):
  262. grid_template_areas_css = "\n".join('"{}"'.format(" ".join(line))
  263. for line in self._grid_template_areas)
  264. self.layout.grid_template_columns = self._grid_template_columns
  265. self.layout.grid_template_rows = self._grid_template_rows
  266. self.layout.grid_template_areas = grid_template_areas_css
  267. self.children = tuple(self._children.values())
  268. @doc_subst(_doc_snippets)
  269. class TwoByTwoLayout(GridBox, LayoutProperties):
  270. """ Define a layout with 2x2 regular grid.
  271. Parameters
  272. ----------
  273. top_left: instance of Widget
  274. top_right: instance of Widget
  275. bottom_left: instance of Widget
  276. bottom_right: instance of Widget
  277. widgets to fill the positions in the layout
  278. merge: bool
  279. flag to say whether the empty positions should be automatically merged
  280. {style_params}
  281. Examples
  282. --------
  283. >>> from ipywidgets import TwoByTwoLayout, Button
  284. >>> TwoByTwoLayout(top_left=Button(description="Top left"),
  285. ... top_right=Button(description="Top right"),
  286. ... bottom_left=Button(description="Bottom left"),
  287. ... bottom_right=Button(description="Bottom right"))
  288. """
  289. # widget positions
  290. top_left = Instance(Widget, allow_none=True)
  291. top_right = Instance(Widget, allow_none=True)
  292. bottom_left = Instance(Widget, allow_none=True)
  293. bottom_right = Instance(Widget, allow_none=True)
  294. # extra args
  295. merge = Bool(default_value=True)
  296. def __init__(self, **kwargs):
  297. super(TwoByTwoLayout, self).__init__(**kwargs)
  298. self._update_layout()
  299. def _update_layout(self):
  300. grid_template_areas = [["top-left", "top-right"],
  301. ["bottom-left", "bottom-right"]]
  302. all_children = {'top-left' : self.top_left,
  303. 'top-right' : self.top_right,
  304. 'bottom-left' : self.bottom_left,
  305. 'bottom-right' : self.bottom_right}
  306. children = {position : child
  307. for position, child in all_children.items()
  308. if child is not None}
  309. if not children:
  310. return
  311. for position, child in children.items():
  312. child.layout.grid_area = position
  313. if self.merge:
  314. if len(children) == 1:
  315. position = list(children.keys())[0]
  316. grid_template_areas = [[position, position],
  317. [position, position]]
  318. else:
  319. columns = ['left', 'right']
  320. for i, column in enumerate(columns):
  321. top, bottom = children.get('top-' + column), children.get('bottom-' + column)
  322. i_neighbour = (i + 1) % 2
  323. if top is None and bottom is None:
  324. # merge each cell in this column with the neighbour on the same row
  325. grid_template_areas[0][i] = grid_template_areas[0][i_neighbour]
  326. grid_template_areas[1][i] = grid_template_areas[1][i_neighbour]
  327. elif top is None:
  328. # merge with the cell below
  329. grid_template_areas[0][i] = grid_template_areas[1][i]
  330. elif bottom is None:
  331. # merge with the cell above
  332. grid_template_areas[1][i] = grid_template_areas[0][i]
  333. grid_template_areas_css = "\n".join('"{}"'.format(" ".join(line))
  334. for line in grid_template_areas)
  335. self.layout.grid_template_columns = '1fr 1fr'
  336. self.layout.grid_template_rows = '1fr 1fr'
  337. self.layout.grid_template_areas = grid_template_areas_css
  338. self.children = tuple(children.values())
  339. @observe("top_left", "bottom_left", "top_right", "bottom_right", "merge")
  340. def _child_changed(self, change): #pylint: disable=unused-argument
  341. self._update_layout()