mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
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:
28
docs/examples/widgets/checkbox.css
Normal file
28
docs/examples/widgets/checkbox.css
Normal 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;
|
||||
}
|
||||
33
docs/examples/widgets/checkbox.py
Normal file
33
docs/examples/widgets/checkbox.py
Normal 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()
|
||||
1
docs/reference/checkbox.md
Normal file
1
docs/reference/checkbox.md
Normal file
@@ -0,0 +1 @@
|
||||
::: textual.widgets.Checkbox
|
||||
57
docs/widgets/checkbox.md
Normal file
57
docs/widgets/checkbox.md
Normal 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
|
||||
@@ -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. |
|
||||
|
||||
|
||||
|
||||
@@ -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
28
sandbox/darren/check.css
Normal 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
24
sandbox/darren/check.py
Normal 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
10
sandbox/will/check.css
Normal 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
21
sandbox/will/check.py
Normal 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()
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
126
src/textual/widgets/_checkbox.py
Normal file
126
src/textual/widgets/_checkbox.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
46
tests/layouts/test_horizontal.py
Normal file
46
tests/layouts/test_horizontal.py
Normal 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
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user