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,
)
)