Source code for wildewidgets.widgets.grid

from __future__ import annotations

import re
from copy import deepcopy
from functools import partial
from typing import Any, Final, cast

from .base import Block


[docs]class Column(Block): """ Implements a ``col`` from the `Bootstrap Grid system <https://getbootstrap.com/docs/5.2/layout/grid/>`_. This is primarily meant to be used with :py:class:`Row`, but you can actually use :py:class:`Column` objects outside of a :py:class:`Row`. This allows you to specify a particular width for the block within a page. See `Boostrap: Columns, Standalone Column Classes <https://getbootstrap.com/docs/5.2/layout/columns/#standalone-column-classes>`_. Example: .. code-block:: python from wildewidgets import Column, Block col = Column( Block("This goes at the top of the column", css_class="mb-2"), Block("This goes at the bottom of the column"), base_width=6, viewport_widths={"md": "4", "lg": "3"}, alignment="center", self_alignment="center" ) Args: *blocks: a list of :py:class:`Block` objects or string to add to this column Keyword Args: base_width: the base width of this block for all viewports. This will be converted to a ``col-{base_width}`` CSS class. If this is not supplied and :py:attr:`base_width` is ``None``, add ``col`` as our CSS class. viewport_widths: a dict where the keys are Bootstrap viewport sizes (e.g. ``sm``, ``xl``) and the values are column widths, again between 1 and 12 inclusive, or "auto". These will be converted to CSS classes that look like ``col-{viewport}-{width}`` alignment: how to align items within this column. Valid choices: ``start``, ``center``, ``end``, ``between``, ``around``, ``evenly``. See `Bootstrap: Flex, justify content <https://getbootstrap.com/docs/5.2/utilities/flex/#justify-content>`_. If not supplied here and :py:attr:`alignment` is ``None``, do whatever aligment Bootstrap does by default. self_alignment: how to align this column vertically within its containing row. Valid choices: ``start``, ``end``, ``center`` See `Bootstrap Columns <https://getbootstrap.com/docs/5.2/layout/columns/#alignment>`_ Raises: ValueError: there was a problem validating one of our settings """ #: The minimum and maximum column widths, as per Bootstrap COL_MIN_WIDTH: Final[int] = 1 COL_MAX_WIDTH: Final[int] = 12 #: The valid column content alignments ALIGNMENTS: Final[list[str]] = [ "start", "center", "end", "between", "around", "evenly", ] #: The valid self vertical alignments with our row SELF_ALIGNMENTS: Final[list[str]] = ["start", "center", "end"] #: A column width between 1 and 12 inclusive. This is the base width #: for all viewports base_width: int | None = None #: A dict where the keys are Bootstrap viewport sizes (e.g. #: ``sm``, #: ``xl``) and the values are column widths, again between 1 and 12 #: inclusive, or "auto" viewport_widths: dict[str, str] = {} # noqa: RUF012 #: How to align items within this column. Valid choices: ``start``, #: ``center``, ``end``, ``between``, ``around``, ``evenly``. See `Bootstrap: Flex, #: justify content <https://getbootstrap.com/docs/5.2/utilities/flex/#justify-content>`_. #: If not supplied here and :py:attr:`alignment` is ``None``, do whatever aligment #: Bootstrap does by default. alignment: str | None = None #: How to align this column vertically within its containing row. Valid #: choices: ``start``, ``end``, ``center``. See `Bootstrap: Columns, #: Alignment : <https://getbootstrap.com/docs/5.2/layout/columns/#alignment>`_ self_alignment: str | None = None def __init__( self, *blocks: Block, base_width: int | None = None, viewport_widths: dict[str, str] | None = None, alignment: str | None = None, self_alignment: str | None = None, **kwargs: Any, ): self.base_width = base_width or self.base_width self.viewport_widths = viewport_widths or deepcopy(self.viewport_widths) self.alignment = alignment or self.alignment self.self_alignment = self_alignment or self.self_alignment self.check_widths() self.check_alignments() super().__init__(*blocks, **kwargs) if self.base_width: self.add_class(f"col-{self.base_width}") elif self.viewport_widths: self.add_class("col-12") else: self.add_class("col") for viewport, w in cast("dict[str, str]", self.viewport_widths).items(): self.add_class(f"col-{viewport}-{w}") if self.alignment or self.self_alignment: self.add_class("d-flex") if self.alignment: self.add_class(f"justify-content-{self.alignment}") if self.self_alignment: self.add_class(f"align-self-{self.self_alignment}")
[docs] def check_widths(self) -> None: """ Validate our supplied ``width`` and ``viewport_widths`` settings to ensure widths for every viewport are between 0 and 12 inclusive, or (in the case of viewport specific widths). Raises: ValueError: a width was out of range """ # TODO: validate viewport names also width: str | int if self.base_width: try: width = int(self.base_width) except ValueError as exc: msg = f'Invalid width "{self.base_width}". Width must be an integer.' raise ValueError(msg) from exc else: if width and (width < 1 or width > self.COL_MAX_WIDTH): msg = ( f"Invalid width {self.base_width}. Width must be 0 > " "width <= 12" ) raise ValueError(msg) if self.viewport_widths: for viewport, width in self.viewport_widths.items(): try: _width = int(width) except ValueError as e: # noqa: PERF203 if _width != "auto": msg = ( f'Invalid width "{_width}" for viewport "{viewport}". ' 'Width must be either "auto" or, an integer 0 > width <= 12' ) raise ValueError(msg) from e else: if _width and ( _width < self.COL_MIN_WIDTH or _width > self.COL_MAX_WIDTH ): msg = ( f'Invalid width {width} for viewport "{viewport}". Width ' "must be an integer 0 > width <= 12" ) raise ValueError(msg)
[docs] def check_alignments(self) -> None: """ Check that our supplied ``alignment`` and ``self_alignment`` settings to ensure they are valid values from :py:attr:`ALIGNMENTS` and :py:attr:`SELF_ALIGNMENTS`, respectively. Raises: ValueError: an alignment was not valid """ if self.alignment and self.alignment not in self.ALIGNMENTS: msg = ( f"Invalid alignment: {self.alignment}. Alignment must be one of " f"{', '.join(self.ALIGNMENTS)}" ) raise ValueError(msg) if self.self_alignment and self.self_alignment not in self.SELF_ALIGNMENTS: msg = ( f"Invalid self_alignment: {self.self_alignment}. " f"Alignment must be one of {', '.join(self.SELF_ALIGNMENTS)}" ) raise ValueError(msg)
[docs]class Row(Block): """ Implements a ``row`` from the `Bootstrap Grid system <https://getbootstrap.com/docs/5.2/layout/grid/>`_. As columns are added to this Row, helper methods are also added to this :py:class:`row` instance, named for the :py:attr:`Column.name` of the column. See :py:meth:`_add_helper_method` for details on how the helper methods will be named. Example: .. code-block:: python from wildewidgets import Row, Column, Block sidebar = Column( Block("This is in the sidebar"), name='sidebar', width=3 ) main = Column( Block("This is in the main content area"), name='main' ) row = Row( sidebar, main, horizontal_alignment='center', vertical_alignment='center' ) Args: *columns: one or more :py:class:`Column` objects Keyword Args: horizontal_alignment: the horizontal alignment for the columns in this row. See `Bootstrap: Columns, Horizontal Alignment <https://getbootstrap.com/docs/5.2/layout/columns/#horizontal-alignment>`_. Valid choices: ``start``, ``center``, ``end``, ``around``, ``between``, ``evenly``. vertical_alignment: the vertical alignment for the columns in this row. See `Bootstrap: Columns, Vertical Alignment <https://getbootstrap.com/docs/5.2/layout/columns/#vertical-alignment>`_. Valid choices: ``start``, ``center``, ``end``. Raises: ValueError: there was a problem validating one of our alignments """ HORIZONTAL_ALIGNMENT: Final[list[str]] = [ "start", "center", "end", "around", "between", "evenly", ] VERTICAL_ALIGNMENT: Final[list[str]] = ["start", "center", "end"] block: str = "row" #: A list of :py:class:`Column` blocks to add to the this row. #: This is List[Block] here so people can specify their own Column-like classes columns: list[Block] = [] # noqa: RUF012 #: the horizontal alignment for the columns in this row. See #: `Bootstrap: Columns, Horizontal Alignment #: <https://getbootstrap.com/docs/5.2/layout/columns/#horizontal-alignment>`_ horizontal_alignment: str | None = None #: the vertical alignment for the columns in this row. See #: `Bootstrap: Columns, Vertical Alignment #: <https://getbootstrap.com/docs/5.2/layout/columns/#vertical-alignment>`_ vertical_alignment: str | None = None def __init__( self, *columns: Block, horizontal_alignment: str | None = None, vertical_alignment: str | None = None, **kwargs: Any, ): self.horizontal_alignment = horizontal_alignment or self.horizontal_alignment self.vertical_alignment = vertical_alignment or self.vertical_alignment self.check_alignments() self.columns: list[Block] = list(columns) self.columns_map: dict[str, Block] = {} for i, column in enumerate(self.columns): name = column._name or f"column-{i + 1}" self.columns_map[name] = column self._add_helper_method(name) super().__init__(**kwargs)
[docs] def check_alignments(self) -> None: """ Check that our supplied ``horizontal_alignment`` and ``verttical_alignment`` settings to ensure they are valid values from :py:attr:`HORIZONTALALIGNMENTS` and :py:attr:`VERTICAL_ALIGNMENTS`, respectively. Raises: ValueError: an alignment was not valid """ if ( self.horizontal_alignment and self.horizontal_alignment not in self.HORIZONTAL_ALIGNMENT ): msg = ( f'"{self.horizontal_alignment}" is not a valid horizontal alignment. ' f"Must be one of {', '.join(self.HORIZONTAL_ALIGNMENT)}" ) raise ValueError(msg) if ( self.vertical_alignment and self.vertical_alignment not in self.VERTICAL_ALIGNMENT ): msg = ( f'"{self.vertical_alignment}" is not a valid vertical alignment. ' f"Must be one of {', '.join(self.VERTICAL_ALIGNMENT)}" ) raise ValueError(msg)
@property def column_names(self) -> list[str]: """ Return the list of :py:attr:`Column.name` attributes of all of our columns. Returns: A list of column names. """ return list(self.columns_map.keys()) def _add_helper_method(self, name: str) -> None: """ Add a method to this :py:class:`Row` object like so:: def add_to_{column_name}(widget: Widget) -> None: ... This new method will allow you to add a widget to the column with name ``{column_name}`` directly without having to use :py:meth:`add_to_column`. Example: .. code-block:: python from wildewidgets.widgets import Row, Column, Block sidebar = Column(name='sidebar', width=3) main = Column(name='main') row = Row(columns=[sidebar, main]) You can now add widgets to the sidebar column like so: .. code-block:: python widget = Block('foo') row.add_to_sidebar(widget) Args: name: the name of the column """ name = re.sub("-", "_", name) setattr(self, f"add_to_{name}", partial(self.add_to_column, name))
[docs] def add_column(self, column: Column) -> None: """ Add a column to this row to the right of any existing columns. Note: A side effect of adding a column is to add a method to this :py:class:`Row` object like so: .. code-block:: python def add_to_{column_name}(widget: Widget) -> None: where ``{column_name}`` is either: * the value of ``column.name``, if that is not the default name * ``column-{n}`` where ``n`` is the number of columns in this row, starting with 1. Args: column: the column to add """ if column._name: name: str = column._name else: name = f"column-{len(self.columns)}" self.add_block(column) self.columns.append(column) self.columns_map[name] = column self._add_helper_method(name)
[docs] def add_to_column(self, identifier: int | str, block: Block) -> None: """ Add ``widget`` to the column named ``identifier`` at the bottom of any other widgets in that column. Note: If ``identifier`` is an int, ``identifier`` should be 1-offset, not 0-offset. Args: identifier: either a column number (left to right, starting with 1), or a column name block: the :py:class:`Block` subclass to append to this col """ if isinstance(identifier, int): identifier = f"column-{identifier}" self.columns_map[identifier].add_block(block)
[docs]class TwoColumnLayout(Row): """ A convenience widget that implements a two column ``row`` from the `Bootstrap Grid system <https://getbootstrap.com/docs/5.2/layout/grid/>`_ with two named columns: ``left`` and ``right``. On viewports less than size ``md``, both columns will take up the entire width of the page (so that we look readable on portrait phones). Set the column widths for viewport ``md`` and above, define ``left_column_width`` as a class attribute or keyword argument, where 0 < ``left_column_width`` <= 12. The default is to make the two columns equal widths. Examples: .. code-block:: python from wildewidgets import TwoColumnLayout, Block left_blocks = [Block('One', name='one'), Block('Two', name='two')] right_blocks = [Block('Three', name='three'), Block('Four', name='four')] layout = TwoColumnLayout( left_column_width=3, left_column_widgets=left_blocks, right_column_widgets=right_blocks ) To create a two column layout with no blocks in either column: .. code-block:: python from wildewidgets import TwoColumnLayout layout = TwoColumnLayout(left_column_width=3) To create a two column layout with blocks in each column: .. code-block:: python from wildewidgets import TwoColumnLayout, Block layout = TwoColumnLayout( left_column_width=3, left_column_widgets=[ Block('One', name='one'), Block('Two', name='two') ], right_column_widgets=[ Block('Three', name='three'), Block('Four', name='four') ] ) To add a block to a column after creating the ``TwoColumnLayout``: .. code-block:: python from wildewidgets import TwoColumnLayout, Block layout = TwoColumnLayout(left_column_width=3) layout.add_to_left_column(Block('One', name='one')) layout.add_to_right_column(Block('Two', name='two')) layout.add_to_left_column(Block('Three', name='three')) layout.add_to_right_column(Block('Four', name='four')) Keyword Args: left_column_width: the width of the left column, where 0 < ``left_column_width`` <= 12. The right column will have its width set automatically based on this. left_column_widgets: A list of blocks to add to the left column right_column_widgets: A list of blocks to add to the right column """ #: The name of this block name: str = "two-column" #: The width of the left column, where 0 < width <= 12. The #: right column will have its width set automatically based on this. left_column_width: int = 6 #: A list of blocks to add to the left column left_column_widgets: list[Block] = [] # noqa: RUF012 #: A list of blocks to add to the right column right_column_widgets: list[Block] = [] # noqa: RUF012 def __init__( self, left_column_width: int | None = None, left_column_widgets: list[Block] | None = None, right_column_widgets: list[Block] | None = None, **kwargs: Any, ): super().__init__(**kwargs) self.left_column_width = left_column_width or self.left_column_width self.left_column_widgets = ( left_column_widgets if left_column_widgets is not None else deepcopy(self.left_column_widgets) ) self.right_column_widgets = ( right_column_widgets if right_column_widgets is not None else deepcopy(self.right_column_widgets) ) left_viewport_widths = {"md": str(self.left_column_width)} right_viewport_widths = {"md": str(12 - self.left_column_width)} self.add_column( Column( *self.left_column_widgets, name="left", base_width=12, viewport_widths=left_viewport_widths, ) ) self.add_column( Column( *self.right_column_widgets, name="right", base_width=12, viewport_widths=right_viewport_widths, ) )