prompt-toolkit / prompt-toolkit/python-prompt-toolkit

ScrollablePane is managing styles differently than HSplit

Open
#1,513 1 comment 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
Python
Stars
10.6k
Forks
815
PR merge metrics
No merged PRs in 30d

Description

I'm trying to create a new component of a table, I'm pretty much there (thanks for the ScrollablePane!) , but I can't figure out how to manage the styles well.

My idea is to:

  • Change the background of each row between two values class:row and class:row.alternate
  • Highlight the focused row with class:focused, till the end of the screen.

Short version

(the full code is below)

When using HSplit directly like:

            ...
            children=[
                self.table_header,
                Window(height=1, char="─", style="class:header.separator"),
                HSplit(self.rows),
            ],

The result is:

The same code with the only change of using ScrollablePane like:

            ...
            children=[
                self.table_header,
                Window(height=1, char="─", style="class:header.separator"),
                ScrollablePane(
                    content=HSplit(self.rows),
                    display_arrows=False,
                ),
            ],

The result changes to:

Long version

I created a Table object that inherited from HSplit, created
a _set_row_widths method that calculated the desired size of each cell based
on the width of the buffer, and set it in the dimensions of the rows.

It also guessed the header from the contents of the data for each of the cases.

Then the data of each row was passed to _Row, a class inherited from VSplit
that set the style of the focused line, created a row of Windows with
a FormattedTextControl component for each row, with only the first one
focusable.

To manage the styles, I've initialized each row with either class:row or class:row.alternate, and then I use a get_style helper function to attach the class:focused style to the focused row components.

from typing import Callable, Generic, List, Optional, TypeVar, Union

from prompt_toolkit.application import get_app
from prompt_toolkit.filters import Condition
from prompt_toolkit.formatted_text import StyleAndTextTuples, to_formatted_text
from prompt_toolkit.formatted_text.utils import fragment_list_to_text
from prompt_toolkit.key_binding import KeyBindings, KeyBindingsBase, merge_key_bindings
from prompt_toolkit.key_binding.key_processor import KeyPressEvent
from prompt_toolkit.keys import Keys
from prompt_toolkit.layout import ScrollablePane
from prompt_toolkit.layout.containers import (
    Container,
    HorizontalAlign,
    HSplit,
    VerticalAlign,
    VSplit,
    Window,
)
from prompt_toolkit.layout.controls import FormattedTextControl
from prompt_toolkit.layout.dimension import AnyDimension, max_layout_dimensions
from prompt_toolkit.layout.margins import ConditionalMargin, ScrollbarMargin
from prompt_toolkit.layout.mouse_handlers import MouseHandlers
from prompt_toolkit.layout.screen import Screen, WritePosition
from prompt_toolkit.mouse_events import MouseEvent, MouseEventType
from prompt_toolkit.styles import Style
from pydantic import BaseModel  # noqa: E0611

from .key_bindings import table_bindings

RowData = TypeVar("RowData")
TableData = List[RowData]


class Table(HSplit):
    """Define a table.

    Args:
        header
        data: Data to print
        handler: Called when the row is clicked, no parameters are passed to this
            callable.
    """

    def __init__(
        self,
        data: TableData[RowData],
        header: Optional[List[str]] = None,
        fill_width: bool = False,
        window_too_small: Optional[Container] = None,
        align: VerticalAlign = VerticalAlign.JUSTIFY,
        padding: AnyDimension = 0,
        padding_char: Optional[str] = None,
        padding_style: str = "",
        width: AnyDimension = None,
        height: AnyDimension = None,
        z_index: Optional[int] = None,
        modal: bool = False,
        key_bindings: Optional[KeyBindingsBase] = None,
        style: Union[str, Callable[[], str]] = "",
    ) -> None:
        """Initialize the widget."""
        self.fill_width = fill_width
        if header is None:
            if isinstance(data[0], list):  # If is a list of lists
                raise ValueError("You need to specify a header for the table")
            elif isinstance(data[0], dict):  # If is a list of dictionaries
                header = [key.title() for key in data[0].keys()]
            elif isinstance(data[0], BaseModel):  # If is a list of pydantic objects
                header = [
                    property["title"]
                    for _, property in data[0].schema()["properties"].items()
                ]

        self.data: List[RowData] = data
        self.header = header
        self.table_header = _Row(self.header, style="class:header", focusable=False)

        if key_bindings is None:
            key_bindings = KeyBindings()
        key_bindings = merge_key_bindings([table_bindings, key_bindings])

        self.rows: List[_Row] = []
        for row in self.data:
            if len(self.rows) % 2 == 0:
                style = "class:row.alternate"
            else:
                style = "class:row"
            self.rows.append(_Row(row, style=style))

        super().__init__(
            children=[
                self.table_header,
                Window(height=1, char="─", style="class:header.separator"),
                ScrollablePane(
                    content=HSplit(self.rows),
                    display_arrows=False,
                ),
            ],
            window_too_small=window_too_small,
            align=align,
            padding=padding,
            padding_char=padding_char,
            padding_style=padding_style,
            width=width,
            height=height,
            z_index=z_index,
            modal=modal,
            key_bindings=key_bindings,
            style=style,
        )

    def _set_row_widths(self, width: int) -> None:
        """Set the row widths.

        Otherwise each row will decided based on their content, breaking the table.
        """
        dimensions = []
        # Get the dimensions of each column of each row in a list of lists
        for row in [self.table_header, *self.rows]:
            dimensions.append([column.preferred_width(width) for column in row.columns])

        # Transpose it so we have a list of column dimensions, so we can get the max
        # per column.
        table_width_dimensions = [
            max_layout_dimensions(list(i)) for i in zip(*dimensions)
        ]

        # Set the max dimension to the preferred if we don't want to use the whole
        # screen
        if not self.fill_width:
            for dimension in table_width_dimensions:
                dimension.max = dimension.preferred

        # Set the widths of all elements
        for row in [self.table_header, *self.rows]:
            height = row.preferred_height(width=width, max_available_height=10000)
            for column_index in range(0, len(table_width_dimensions)):
                row.columns[column_index].width = table_width_dimensions[column_index]
                row.columns[column_index].height = height

    def write_to_screen(
        self,
        screen: Screen,
        mouse_handlers: MouseHandlers,
        write_position: WritePosition,
        parent_style: str,
        erase_bg: bool,
        z_index: Optional[int],
    ) -> None:
        """
        Render the prompt to a `Screen` instance.

        :param screen: The :class:`~prompt_toolkit.layout.screen.Screen` class
            to which the output has to be written.
        """
        self._set_row_widths(write_position.width)
        super().write_to_screen(
            screen=screen,
            mouse_handlers=mouse_handlers,
            write_position=write_position,
            parent_style=parent_style,
            erase_bg=erase_bg,
            z_index=z_index,
        )


class _Row(VSplit):
    """Define row.

    Args:
        text: text to print
    """

    def __init__(
        self,
        data: RowData,
        focusable: bool = True,
        window_too_small: Optional[Container] = None,
        align: HorizontalAlign = HorizontalAlign.LEFT,
        padding: AnyDimension = 3,
        padding_char: Optional[str] = " ",
        padding_style: str = "",
        width: AnyDimension = None,
        height: AnyDimension = None,
        z_index: Optional[int] = None,
        modal: bool = False,
        key_bindings: Optional[KeyBindingsBase] = None,
        style: Union[str, Callable[[], str]] = "class:row",
    ) -> None:
        """Initialize the widget."""
        # Define the row data
        self.data = data

        if isinstance(data, list):
            column_data = data
        elif isinstance(data, dict):
            column_data = [value for _, value in data.items()]
        elif isinstance(data, BaseModel):
            column_data = [value for _, value in data.dict().items()]

        # Define the row style
        def get_style() -> Union[str, Callable[[], str]]:
            if get_app().layout.has_focus(self):
                return f"{self.style},focused"
            else:
                return self.style

        self.columns: List[Window] = []

        for value in column_data:
            # Only allow to focus the first cell of a row
            if focusable and len(self.columns) == 0:
                focusable = True
            else:
                focusable = False

            self.columns.append(
                Window(
                    FormattedTextControl(str(value), focusable=focusable),
                    style=get_style,  # type: ignore
                    always_hide_cursor=True,
                    dont_extend_height=True,
                    wrap_lines=True,
                )
            )
        super().__init__(
            children=self.columns,
            window_too_small=window_too_small,
            align=align,
            padding=padding,
            padding_char=padding_char,
            padding_style=get_style, # type: ignore
            width=width,
            height=height,
            z_index=z_index,
            modal=modal,
            key_bindings=key_bindings,
            style=style,
        )

If I try to use get_style in the super().__init__() call of _Row I get an RecursionError: maximum recursion depth exceeded in comparison error.

And I have to add # type: ignore when setting the get_style in the _Row padding_style and in the Window style argument as mypy shows Argument "style" to "Window" has incompatible type "Callable[[], Union[str, Callable[[], str]]]"; expected "Union[str, Callable[[], str]]", which makes me think I'm defining the get_style function wrong :(

And even with the HSplit case, I didn't manage to make the highlighted background color go till the end of the screen.

I planned to create a separate package until the interface is stable, and if you like the approach, I'd do a PR with it's contents.

Sorry for the long post and I hope you can help me :)

Contributor guide

No contributing guide indexed for this repository

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with the ScrollablePane and HSplit behavior described in the issue, then inspect the pasted Table and _Row examples to understand how styles are propagated. There is no repository file or test named in the report; done would require a confirmed project change or documented approach for focused-row and full-width styling.

Written by the indexing model from the issue text.

Assessment

Tech stack
python
Domain
cli
Issue type
Feature
Difficulty
4/5
Estimated time
3-5 days
Activity status
Stale
Clarity
Needs clarification
Newbie friendliness
25/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.