Checkbox polishing + fix auto-width in Horizontal layout (#942)

* checkbox widget

* fixes

* Checkbox additions, fix content width in horizontal layout

* Update docs, add tests for checkbox

* Remove some test code

* Small renaming of test class

Co-authored-by: Will McGugan <willmcgugan@gmail.com>
This commit is contained in:
darrenburns
2022-10-18 15:17:44 +01:00
committed by GitHub
parent 8c075561a2
commit 4a0dc49bca
19 changed files with 595 additions and 21 deletions

View File

@@ -0,0 +1,28 @@
Screen {
align: center middle;
}
.container {
height: auto;
width: auto;
}
Checkbox {
height: auto;
width: auto;
}
.label {
height: 3;
content-align: center middle;
width: auto;
}
#custom-design {
background: darkslategrey;
}
#custom-design > .checkbox--switch {
color: dodgerblue;
background: darkslateblue;
}

View File

@@ -0,0 +1,33 @@
from textual.app import App, ComposeResult
from textual.containers import Horizontal
from textual.widgets import Checkbox, Static
class CheckboxApp(App):
def compose(self) -> ComposeResult:
yield Static("[b]Example checkboxes\n", classes="label")
yield Horizontal(
Static("off: ", classes="label"), Checkbox(), classes="container"
)
yield Horizontal(
Static("on: ", classes="label"),
Checkbox(value=True),
classes="container",
)
focused_checkbox = Checkbox()
focused_checkbox.focus()
yield Horizontal(
Static("focused: ", classes="label"), focused_checkbox, classes="container"
)
yield Horizontal(
Static("custom: ", classes="label"),
Checkbox(id="custom-design"),
classes="container",
)
app = CheckboxApp(css_path="checkbox.css")
if __name__ == "__main__":
app.run()

View File

@@ -0,0 +1 @@
::: textual.widgets.Checkbox

57
docs/widgets/checkbox.md Normal file
View File

@@ -0,0 +1,57 @@
# Checkbox
A simple checkbox widget which stores a boolean value.
- [x] Focusable
- [ ] Container
## Example
The example below shows checkboxes in various states.
=== "Output"
```{.textual path="docs/examples/widgets/checkbox.py"}
```
=== "checkbox.py"
```python
--8<-- "docs/examples/widgets/checkbox.py"
```
=== "checkbox.css"
```css
--8<-- "docs/examples/widgets/checkbox.css"
```
## Reactive Attributes
| Name | Type | Default | Description |
|---------|--------|---------|------------------------------------|
| `value` | `bool` | `False` | The default value of the checkbox. |
## Messages
### Pressed
The `Checkbox.Changed` message is sent when the checkbox is toggled.
- [x] Bubbles
#### Attributes
| attribute | type | purpose |
|-----------|--------|--------------------------------|
| `value` | `bool` | The new value of the checkbox. |
## Additional Notes
- To remove the spacing around a checkbox, set `border: none;` and `padding: 0;`.
- The `.checkbox--switch` component class can be used to change the color and background of the switch.
- When focused, the ++enter++ or ++space++ keys can be used to toggle the checkbox.
## See Also
- [Checkbox](../reference/checkbox.md) code reference

View File

@@ -54,7 +54,7 @@ The `Input.Submitted` message is sent when you press ++enter++ with the text fie
#### Attributes
| attribute | type | purpose |
| --------- | ----- | -------------------------------- |
|-----------|-------|----------------------------------|
| `value` | `str` | The new value in the text input. |

View File

@@ -6,7 +6,7 @@ edit_uri: edit/css/docs/
nav:
- Introduction:
- "index.md"
- "getting_started.md"
- "getting_started.md"
- "tutorial.md"
- Guide:
- "guide/index.md"
@@ -92,6 +92,7 @@ nav:
- Widgets:
- "widgets/index.md"
- "widgets/button.md"
- "widgets/checkbox.md"
- "widgets/data_table.md"
- "widgets/footer.md"
- "widgets/header.md"

28
sandbox/darren/check.css Normal file
View File

@@ -0,0 +1,28 @@
Screen {
align: center middle;
}
Container {
width: 50;
height: 15;
background: $boost;
align: center middle;
}
Checkbox {
}
#check {
background: red;
border: none;
padding: 0;
}
#check > .checkbox--switch {
color: red;
background: blue;
}
#check:focus {
tint: magenta 60%;
}

24
sandbox/darren/check.py Normal file
View File

@@ -0,0 +1,24 @@
from textual.app import App, ComposeResult
from textual.containers import Container
from textual.widgets import Checkbox, Footer
class CheckboxApp(App):
BINDINGS = [("s", "switch", "Press switch"), ("d", "toggle_dark", "Dark mode")]
def compose(self) -> ComposeResult:
yield Footer()
yield Container(Checkbox(id="check", animate=True))
def action_switch(self) -> None:
checkbox = self.query_one(Checkbox)
checkbox.toggle()
def key_f(self):
print(self.app.focused)
app = CheckboxApp(css_path="check.css")
if __name__ == "__main__":
app.run()

10
sandbox/will/check.css Normal file
View File

@@ -0,0 +1,10 @@
Screen {
align: center middle;
}
Container {
width: 50;
height: 15;
background: $boost;
align: center middle;
}

21
sandbox/will/check.py Normal file
View File

@@ -0,0 +1,21 @@
from textual.app import App, ComposeResult
from textual.containers import Container
from textual.widgets import Checkbox, Footer
class CheckboxApp(App):
BINDINGS = [("s", "switch", "Press switch"), ("d", "toggle_dark", "Dark mode")]
def compose(self) -> ComposeResult:
yield Footer()
yield Container(Checkbox())
def action_switch(self) -> None:
checkbox = self.query_one(Checkbox)
checkbox.value = not checkbox.value
app = CheckboxApp(css_path="check.css")
if __name__ == "__main__":
app.run()

View File

@@ -65,17 +65,18 @@ class Layout(ABC):
int: Width of the content.
"""
width: int | None = None
widget_gutter = widget.gutter.width
gutter_width = widget.gutter.width
for child in widget.displayed_children:
if not child.is_container:
child_width = (
child.get_content_width(container, viewport)
+ widget_gutter
+ gutter_width
+ child.gutter.width
)
width = child_width if width is None else max(width, child_width)
if width is None:
width = container.width
return width
def get_content_height(

View File

@@ -64,3 +64,33 @@ class HorizontalLayout(Layout):
x = next_x + margin
return placements, set(displayed_children)
def get_content_width(self, widget: Widget, container: Size, viewport: Size) -> int:
"""Get the width of the content. In Horizontal layout, the content width of
a widget is the sum of the widths of its children.
Args:
widget (Widget): The container widget.
container (Size): The container size.
viewport (Size): The viewport size.
Returns:
int: Width of the content.
"""
width: int | None = None
gutter_width = widget.gutter.width
for child in widget.displayed_children:
if not child.is_container:
child_width = (
child.get_content_width(container, viewport)
+ gutter_width
+ child.gutter.width
)
if width is None:
width = child_width
else:
width += child_width
if width is None:
width = container.width
return width

View File

@@ -11,6 +11,7 @@ if typing.TYPE_CHECKING:
# `__init__.pyi` file in this same folder - otherwise text editors and type checkers won't be able to "see" them.
__all__ = [
"Button",
"Checkbox",
"DataTable",
"DirectoryTree",
"Footer",

View File

@@ -1,6 +1,7 @@
# This stub file must re-export every classes exposed in the __init__.py's `__all__` list:
from ._button import Button as Button
from ._data_table import DataTable as DataTable
from ._checkbox import Checkbox as Checkbox
from ._directory_tree import DirectoryTree as DirectoryTree
from ._footer import Footer as Footer
from ._header import Header as Header

View File

@@ -0,0 +1,126 @@
from __future__ import annotations
from typing import ClassVar
from rich.console import RenderableType
from ..binding import Binding
from ..geometry import Size
from ..message import Message
from ..reactive import reactive
from ..widget import Widget
from ..scrollbar import ScrollBarRender
class Checkbox(Widget, can_focus=True):
"""A checkbox widget. Represents a boolean value. Can be toggled by clicking
on it or by pressing the enter key or space bar while it has focus.
Args:
value (bool, optional): The initial value of the checkbox. Defaults to False.
animate (bool, optional): True if the checkbox should animate when toggled. Defaults to True.
"""
DEFAULT_CSS = """
Checkbox {
border: tall transparent;
background: $panel;
height: auto;
width: auto;
padding: 0 2;
}
Checkbox > .checkbox--switch {
background: $panel-darken-2;
color: $panel-lighten-2;
}
Checkbox:hover {
border: tall $background;
}
Checkbox:focus {
border: tall $accent;
}
Checkbox.-on {
}
Checkbox.-on > .checkbox--switch {
color: $success;
}
"""
BINDINGS = [
Binding("enter,space", "toggle", "toggle", show=False),
]
COMPONENT_CLASSES: ClassVar[set[str]] = {
"checkbox--switch",
}
value = reactive(False, init=False)
slider_pos = reactive(0.0)
def __init__(
self,
value: bool = None,
*,
animate: bool = True,
name: str | None = None,
id: str | None = None,
classes: str | None = None,
):
super().__init__(name=name, id=id, classes=classes)
if value:
self.slider_pos = 1.0
self._reactive_value = value
self._should_animate = animate
def watch_value(self, value: bool) -> None:
target_slider_pos = 1.0 if value else 0.0
if self._should_animate:
self.animate("slider_pos", target_slider_pos, duration=0.3)
else:
self.slider_pos = target_slider_pos
self.emit_no_wait(self.Changed(self, self.value))
def watch_slider_pos(self, slider_pos: float) -> None:
self.set_class(slider_pos == 1, "-on")
def render(self) -> RenderableType:
style = self.get_component_rich_style("checkbox--switch")
return ScrollBarRender(
virtual_size=100,
window_size=50,
position=self.slider_pos * 50,
style=style,
vertical=False,
)
def get_content_width(self, container: Size, viewport: Size) -> int:
return 4
def get_content_height(self, container: Size, viewport: Size, width: int) -> int:
return 1
def on_click(self) -> None:
self.toggle()
def action_toggle(self) -> None:
self.toggle()
def toggle(self) -> None:
"""Toggle the checkbox value. As a result of the value changing,
a Checkbox.Changed message will be emitted."""
self.value = not self.value
class Changed(Message, bubble=True):
"""Checkbox was toggled."""
def __init__(self, sender: Checkbox, value: bool) -> None:
super().__init__(sender)
self.value = value
self.input = sender

View File

@@ -85,7 +85,7 @@ class Input(Widget, can_focus=True):
Binding("home", "home", "home", show=False),
Binding("end", "end", "end", show=False),
Binding("ctrl+d", "delete_right", "delete right", show=False),
Binding("enter", "submit", "Submit", show=False),
Binding("enter", "submit", "submit", show=False),
]
COMPONENT_CLASSES = {"input--cursor", "input--placeholder"}
@@ -180,22 +180,6 @@ class Input(Widget, can_focus=True):
return placeholder
return _InputRenderable(self, self._cursor_visible)
class Changed(Message, bubble=True):
"""Value was changed."""
def __init__(self, sender: Input, value: str) -> None:
super().__init__(sender)
self.value = value
self.input = sender
class Submitted(Message, bubble=True):
"""Value was updated via enter key or blur."""
def __init__(self, sender: Input, value: str) -> None:
super().__init__(sender)
self.value = value
self.input = sender
@property
def _value(self) -> Text:
"""Value rendered as text."""
@@ -323,3 +307,19 @@ class Input(Widget, can_focus=True):
async def action_submit(self) -> None:
await self.emit(self.Submitted(self, self.value))
class Changed(Message, bubble=True):
"""Value was changed."""
def __init__(self, sender: Input, value: str) -> None:
super().__init__(sender)
self.value = value
self.input = sender
class Submitted(Message, bubble=True):
"""Value was updated via enter key or blur."""
def __init__(self, sender: Input, value: str) -> None:
super().__init__(sender)
self.value = value
self.input = sender

View File

@@ -0,0 +1,46 @@
from textual.geometry import Size
from textual.layouts.horizontal import HorizontalLayout
from textual.widget import Widget
class SizedWidget(Widget):
"""Simple Widget wrapped allowing you to modify the return values for
get_content_width and get_content_height via the constructor."""
def __init__(
self,
*children: Widget,
content_width: int = 10,
content_height: int = 5,
):
super().__init__(*children)
self.content_width = content_width
self.content_height = content_height
def get_content_width(self, container: Size, viewport: Size) -> int:
return self.content_width
def get_content_height(self, container: Size, viewport: Size, width: int) -> int:
return self.content_height
CHILDREN = [
SizedWidget(content_width=10, content_height=5),
SizedWidget(content_width=4, content_height=2),
SizedWidget(content_width=12, content_height=3),
]
def test_horizontal_get_content_width():
parent = Widget(*CHILDREN)
layout = HorizontalLayout()
width = layout.get_content_width(widget=parent, container=Size(), viewport=Size())
assert width == sum(child.content_width for child in CHILDREN)
def test_horizontal_get_content_width_no_children():
parent = Widget()
layout = HorizontalLayout()
container_size = Size(24, 24)
width = layout.get_content_width(widget=parent, container=container_size, viewport=Size())
assert width == container_size.width

File diff suppressed because one or more lines are too long

View File

@@ -24,3 +24,9 @@ def test_vertical_layout(snap_compare):
def test_dock_layout_sidebar(snap_compare):
assert snap_compare("docs/examples/guide/layout/dock_layout2_sidebar.py")
def test_checkboxes(snap_compare):
"""Tests checkboxes but also acts a regression test for using
width: auto in a Horizontal layout context."""
assert snap_compare("docs/examples/widgets/checkbox.py")