prompt-toolkit / prompt-toolkit/python-prompt-toolkit
ScrollablePane is managing styles differently than HSplit
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:rowandclass: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
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- 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