Skip to content

Commit

Permalink
Select widget (#2501)
Browse files Browse the repository at this point in the history
* overlay rule

* select WIP

* select control, made binding description optional

* changelog

* style tweak

* Added constrain

* changelog

* test fix

* drop markup, tidy

* tidy

* select namespace

* tests

* docs

* Added changed event

* changelog

* expanded

* tests and snapshits

* examples and docs

* simplify

* update reactive attributes

* type fix

* docstrings

* allow renderables

* superfluous init

* typing fix

* optimization

* revert optimizations

* fixed words

* changelog

* docstrings

* don't need this

* changelog

* comment

* Update docs/widgets/select.md

Co-authored-by: Dave Pearson <davep@davep.org>

* review changes

* review updates

---------

Co-authored-by: Dave Pearson <davep@davep.org>
  • Loading branch information
willmcgugan and davep committed May 8, 2023
1 parent c2a19bd commit 7db7139
Show file tree
Hide file tree
Showing 32 changed files with 3,728 additions and 2,561 deletions.
15 changes: 8 additions & 7 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,23 +18,24 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- The DataTable cursor is now scrolled into view when the cursor coordinate is changed programmatically https://github.com/Textualize/textual/issues/2459
- run_worker exclusive parameter is now `False` by default https://github.com/Textualize/textual/pull/2470
- Added `always_update` as an optional argument for `reactive.var`
- `TabbedContent` now takes kwargs `id`, `name`, `classes`, and `disabled`, upon initialization, like other widgets https://github.com/Textualize/textual/pull/2497

- Made Binding description default to empty string, which is equivalent to show=False https://github.com/Textualize/textual/pull/2501
- Modified Message to allow it to be used as a dataclass https://github.com/Textualize/textual/pull/2501

### Added

- Experimental: Added "overlay" rule https://github.com/Textualize/textual/pull/2501
- Experimental: Added "constrain" rule https://github.com/Textualize/textual/pull/2501
- Added textual.widgets.Select https://github.com/Textualize/textual/pull/2501
- Added Region.translate_inside https://github.com/Textualize/textual/pull/2501
- `TabbedContent` now takes kwargs `id`, `name`, `classes`, and `disabled`, upon initialization, like other widgets https://github.com/Textualize/textual/pull/2497
- Method `DataTable.move_cursor` https://github.com/Textualize/textual/issues/2472

### Added

- Added `OptionList.add_options` https://github.com/Textualize/textual/pull/2508

### Added

- Added `TreeNode.is_root` https://github.com/Textualize/textual/pull/2510
- Added `TreeNode.remove_children` https://github.com/Textualize/textual/pull/2510
- Added `TreeNode.remove` https://github.com/Textualize/textual/pull/2510


## [0.23.0] - 2023-05-03

### Fixed
Expand Down
11 changes: 11 additions & 0 deletions docs/examples/widgets/select.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
Screen {
align: center top;
}

Select {
width: 60;
margin: 2;
}
Input {
width: 60;
}
26 changes: 26 additions & 0 deletions docs/examples/widgets/select_widget.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
from textual import on
from textual.app import App, ComposeResult
from textual.widgets import Header, Select

LINES = """I must not fear.
Fear is the mind-killer.
Fear is the little-death that brings total obliteration.
I will face my fear.
I will permit it to pass over me and through me.""".splitlines()


class SelectApp(App):
CSS_PATH = "select.css"

def compose(self) -> ComposeResult:
yield Header()
yield Select((line, line) for line in LINES)

@on(Select.Changed)
def select_changed(self, event: Select.Changed) -> None:
self.title = str(event.value)


if __name__ == "__main__":
app = SelectApp()
app.run()
10 changes: 10 additions & 0 deletions docs/widget_gallery.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,16 @@ A collection of radio buttons, that enforces uniqueness.
```{.textual path="docs/examples/widgets/radio_set.py"}
```

## Select

Select from a number of possible options.

[Select reference](./widgets/select.md){ .md-button .md-button--primary }

```{.textual path="docs/examples/widgets/select_widget.py" press="tab,enter,down,down"}
```


## Static

Displays simple static content. Typically used as a base class.
Expand Down
90 changes: 90 additions & 0 deletions docs/widgets/select.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# Select

!!! tip "Added in version 0.24.0"

A Select widget is a compact control to allow the user to select between a number of possible options.


- [X] Focusable
- [ ] Container


The options in a select control may be passed in to the constructor or set later with [set_options][textual.widgets.Select.set_options].
Options should be given as a sequence of tuples consisting of two values: the first is the string (or [Rich Renderable](https://rich.readthedocs.io/en/latest/protocol.html)) to display in the control and list of options, the second is the value of option.

The value of the currently selected option is stored in the `value` attribute of the widget, and the `value` attribute of the [Changed][textual.widgets.Select.Changed] message.


## Typing

The `Select` control is a typing Generic which allows you to set the type of the option values.
For instance, if the data type for your values is an integer, you would type the widget as follows:

```python
options = [("First", 1), ("Second", 2)]
my_select: Select[int] = Select(options)
```

!!! note

Typing is entirely optional.

If you aren't familiar with typing or don't want to worry about it right now, feel free to ignore it.

## Example

The following example presents a `Select` with a number of options.

=== "Output"

```{.textual path="docs/examples/widgets/select_widget.py"}
```

=== "Output (expanded)"

```{.textual path="docs/examples/widgets/select_widget.py" press="tab,enter,down,down"}
```


=== "select_widget.py"

```python
--8<-- "docs/examples/widgets/select_widget.py"
```

=== "select.css"

```sass
--8<-- "docs/examples/widgets/select.css"
```

## Messages

- [Select.Changed][textual.widgets.Select.Changed]


## Reactive attributes


| Name | Type | Default | Description |
| ---------- | -------------------- | ------- | ----------------------------------- |
| `expanded` | `bool` | `False` | True to expand the options overlay. |
| `value` | `SelectType \| None` | `None` | Current value of the Select. |


## Bindings

The Select widget defines the following bindings:

::: textual.widgets.Select.BINDINGS
options:
show_root_heading: false
show_root_toc_entry: false


---


::: textual.widgets.Select
options:
heading_level: 2
1 change: 1 addition & 0 deletions mkdocs-nav.yml
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ nav:
- "widgets/progress_bar.md"
- "widgets/radiobutton.md"
- "widgets/radioset.md"
- "widgets/select.md"
- "widgets/static.md"
- "widgets/switch.md"
- "widgets/tabbed_content.md"
Expand Down
11 changes: 8 additions & 3 deletions src/textual/_arrange.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ def arrange(
dock_layers: defaultdict[str, list[Widget]] = defaultdict(list)
for child in children:
if child.display:
dock_layers[child.styles.layer or "default"].append(child)
dock_layers[child.layer].append(child)

width, height = size

Expand Down Expand Up @@ -121,9 +121,14 @@ def arrange(
if placement_offset:
layout_placements = [
_WidgetPlacement(
_region + placement_offset, margin, layout_widget, order, fixed
_region + placement_offset,
margin,
layout_widget,
order,
fixed,
overlay,
)
for _region, margin, layout_widget, order, fixed in layout_placements
for _region, margin, layout_widget, order, fixed, overlay in layout_placements
]

placements.extend(layout_placements)
Expand Down
29 changes: 19 additions & 10 deletions src/textual/_compositor.py
Original file line number Diff line number Diff line change
Expand Up @@ -512,6 +512,8 @@ def _arrange_root(
add_new_widget = widgets.add
layer_order: int = 0

no_clip = size.region

def add_widget(
widget: Widget,
virtual_region: Region,
Expand Down Expand Up @@ -586,12 +588,13 @@ def add_widget(
layers_to_index = {
layer_name: index for index, layer_name in enumerate(_layers)
}

get_layer_index = layers_to_index.get

scroll_spacing = arrange_result.scroll_spacing

# Add all the widgets
for sub_region, margin, sub_widget, z, fixed in reversed(
for sub_region, margin, sub_widget, z, fixed, overlay in reversed(
placements
):
layer_index = get_layer_index(sub_widget.layer, 0)
Expand All @@ -608,13 +611,23 @@ def add_widget(

widget_order = order + ((layer_index, z, layer_order),)

if overlay:
constrain = sub_widget.styles.constrain
if constrain != "none":
# Constrain to avoid clipping
widget_region = widget_region.translate_inside(
no_clip,
constrain in ("x", "both"),
constrain in ("y", "both"),
)

add_widget(
sub_widget,
sub_region,
widget_region,
widget_order,
((1, 0, 0),) if overlay else widget_order,
layer_order,
sub_clip,
no_clip if overlay else sub_clip,
visible,
)

Expand Down Expand Up @@ -991,13 +1004,9 @@ def _render_chops(
first_cut, last_cut = render_region.column_span
final_cuts = [cut for cut in cuts[y] if (last_cut >= cut >= first_cut)]

if len(final_cuts) <= 2:
# Two cuts, which means the entire line
cut_strips = [strip]
else:
render_x = render_region.x
relative_cuts = [cut - render_x for cut in final_cuts[1:]]
cut_strips = strip.divide(relative_cuts)
render_x = render_region.x
relative_cuts = [cut - render_x for cut in final_cuts[1:]]
cut_strips = strip.divide(relative_cuts)

# Since we are painting front to back, the first segments for a cut "wins"
get_chops_line = chops_line.get
Expand Down
2 changes: 2 additions & 0 deletions src/textual/_layout.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ def spatial_map(self) -> SpatialMap[WidgetPlacement]:
(
placement.region.grow(placement.margin),
placement.fixed,
placement.overlay,
placement,
)
for placement in self.placements
Expand Down Expand Up @@ -73,6 +74,7 @@ class WidgetPlacement(NamedTuple):
widget: Widget
order: int = 0
fixed: bool = False
overlay: bool = False


class Layout(ABC):
Expand Down
7 changes: 4 additions & 3 deletions src/textual/_spatial_map.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ def _region_to_grid_coordinates(self, region: Region) -> Iterable[GridCoordinate
)

def insert(
self, regions_and_values: Iterable[tuple[Region, bool, ValueType]]
self, regions_and_values: Iterable[tuple[Region, bool, bool, ValueType]]
) -> None:
"""Insert values into the Spatial map.
Expand All @@ -71,8 +71,9 @@ def insert(
get_grid_list = self._map.__getitem__
_region_to_grid = self._region_to_grid_coordinates
total_region = self.total_region
for region, fixed, value in regions_and_values:
total_region = total_region.union(region)
for region, fixed, overlay, value in regions_and_values:
if not overlay:
total_region = total_region.union(region)
if fixed:
append_fixed(value)
else:
Expand Down
12 changes: 6 additions & 6 deletions src/textual/binding.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
if TYPE_CHECKING:
from typing_extensions import TypeAlias

BindingType: TypeAlias = "Binding | tuple[str, str, str]"
BindingType: TypeAlias = "Binding | tuple[str, str] | tuple[str, str, str]"


class BindingError(Exception):
Expand All @@ -41,7 +41,7 @@ class Binding:
"""Key to bind. This can also be a comma-separated list of keys to map multiple keys to a single action."""
action: str
"""Action to bind to."""
description: str
description: str = ""
"""Description of action."""
show: bool = True
"""Show the action in Footer, or False to hide."""
Expand Down Expand Up @@ -74,9 +74,9 @@ def make_bindings(bindings: Iterable[BindingType]) -> Iterable[Binding]:
for binding in bindings:
# If it's a tuple of length 3, convert into a Binding first
if isinstance(binding, tuple):
if len(binding) != 3:
if len(binding) not in (2, 3):
raise BindingError(
f"BINDINGS must contain a tuple of three strings, not {binding!r}"
f"BINDINGS must contain a tuple of two or three strings, not {binding!r}"
)
binding = Binding(*binding)

Expand All @@ -95,7 +95,7 @@ def make_bindings(bindings: Iterable[BindingType]) -> Iterable[Binding]:
key=key,
action=binding.action,
description=binding.description,
show=binding.show,
show=bool(binding.description and binding.show),
key_display=binding.key_display,
priority=binding.priority,
)
Expand Down Expand Up @@ -165,7 +165,7 @@ def bind(
key,
action,
description,
show=show,
show=bool(description and show),
key_display=key_display,
priority=priority,
)
Expand Down
Loading

0 comments on commit 7db7139

Please sign in to comment.