Skip to content

Commit 7cceaeb

Browse files
authored
Merge pull request #5778 from Textualize/compact-widgets
Compact widgets
2 parents 7f5ed0e + a6d6d4c commit 7cceaeb

16 files changed

+361
-42
lines changed

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,21 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](http://keepachangelog.com/)
66
and this project adheres to [Semantic Versioning](http://semver.org/).
77

8+
## Unreleased
9+
810
### Fixed
911

1012
- Fixed `OptionList` causing excessive redrawing https://github.com/Textualize/textual/pull/5766
1113

14+
### Added
15+
16+
- Added `toggle_class` parameter to reactives https://github.com/Textualize/textual/pull/5778
17+
- Added `compact` parameter and reactive to `Button`, `Input`, `ToggleButton`, `RadioSet`, `OptionList`, `TextArea` https://github.com/Textualize/textual/pull/5778
18+
19+
### Changed
20+
21+
- `RadioSet` now has a default width of `1fr` https://github.com/Textualize/textual/pull/5778
22+
1223
## [3.1.1] - 2025-04-22
1324

1425
### Fixed

src/textual/reactive.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ class Reactive(Generic[ReactiveType]):
111111
compute: Run compute methods when attribute is changed.
112112
recompose: Compose the widget again when the attribute changes.
113113
bindings: Refresh bindings when the reactive changes.
114+
toggle_class: An optional TCSS classname(s) to toggle based on the truthiness of the value.
114115
"""
115116

116117
_reactives: ClassVar[dict[str, object]] = {}
@@ -126,6 +127,7 @@ def __init__(
126127
compute: bool = True,
127128
recompose: bool = False,
128129
bindings: bool = False,
130+
toggle_class: str | None = None,
129131
) -> None:
130132
self._default = default
131133
self._layout = layout
@@ -135,6 +137,7 @@ def __init__(
135137
self._run_compute = compute
136138
self._recompose = recompose
137139
self._bindings = bindings
140+
self._toggle_class = toggle_class
138141
self._owner: Type[MessageTarget] | None = None
139142
self.name: str
140143

@@ -175,6 +178,7 @@ def _initialize_reactive(self, obj: Reactable, name: str) -> None:
175178
name: Name of attribute.
176179
"""
177180
_rich_traceback_omit = True
181+
178182
internal_name = f"_reactive_{name}"
179183
if hasattr(obj, internal_name):
180184
# Attribute already has a value
@@ -308,6 +312,11 @@ def _set(self, obj: Reactable, value: ReactiveType, always: bool = False) -> Non
308312
public_validate_function = getattr(obj, f"validate_{name}", None)
309313
if callable(public_validate_function):
310314
value = public_validate_function(value)
315+
316+
# Toggle the classes using the value's truthiness
317+
if (toggle_class := self._toggle_class) is not None:
318+
obj.set_class(bool(value), *toggle_class.split())
319+
311320
# If the value has changed, or this is the first time setting the value
312321
if always or self._always_update or current_value != value:
313322
# Store the internal value
@@ -407,6 +416,7 @@ class reactive(Reactive[ReactiveType]):
407416
always_update: Call watchers even when the new value equals the old value.
408417
recompose: Compose the widget again when the attribute changes.
409418
bindings: Refresh bindings when the reactive changes.
419+
toggle_class: An optional TCSS classname(s) to toggle based on the truthiness of the value.
410420
"""
411421

412422
def __init__(
@@ -419,6 +429,7 @@ def __init__(
419429
always_update: bool = False,
420430
recompose: bool = False,
421431
bindings: bool = False,
432+
toggle_class: str | None = None,
422433
) -> None:
423434
super().__init__(
424435
default,
@@ -428,6 +439,7 @@ def __init__(
428439
always_update=always_update,
429440
recompose=recompose,
430441
bindings=bindings,
442+
toggle_class=toggle_class,
431443
)
432444

433445

@@ -439,6 +451,7 @@ class var(Reactive[ReactiveType]):
439451
init: Call watchers on initialize (post mount).
440452
always_update: Call watchers even when the new value equals the old value.
441453
bindings: Refresh bindings when the reactive changes.
454+
toggle_class: An optional TCSS classname(s) to toggle based on the truthiness of the value.
442455
"""
443456

444457
def __init__(
@@ -447,6 +460,7 @@ def __init__(
447460
init: bool = True,
448461
always_update: bool = False,
449462
bindings: bool = False,
463+
toggle_class: str | None = None,
450464
) -> None:
451465
super().__init__(
452466
default,
@@ -455,6 +469,7 @@ def __init__(
455469
init=init,
456470
always_update=always_update,
457471
bindings=bindings,
472+
toggle_class=toggle_class,
458473
)
459474

460475

src/textual/widgets/_button.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,10 @@ class Button(Widget, can_focus=True):
5959
text-style: bold;
6060
line-pad: 1;
6161
62+
&.-textual-compact {
63+
border: none !important;
64+
}
65+
6266
&:disabled {
6367
text-opacity: 0.6;
6468
}
@@ -160,6 +164,9 @@ class Button(Widget, can_focus=True):
160164
variant = reactive("default", init=False)
161165
"""The variant name for the button."""
162166

167+
compact = reactive(False, toggle_class="-textual-compact")
168+
"""Make the button compact (without borders)."""
169+
163170
class Pressed(Message):
164171
"""Event sent when a `Button` is pressed and there is no Button action.
165172
@@ -191,6 +198,7 @@ def __init__(
191198
disabled: bool = False,
192199
tooltip: RenderableType | None = None,
193200
action: str | None = None,
201+
compact: bool = False,
194202
):
195203
"""Create a Button widget.
196204
@@ -203,6 +211,7 @@ def __init__(
203211
disabled: Whether the button is disabled or not.
204212
tooltip: Optional tooltip.
205213
action: Optional action to run when clicked.
214+
compact: Enable compact button style.
206215
"""
207216
super().__init__(name=name, id=id, classes=classes, disabled=disabled)
208217

@@ -212,6 +221,7 @@ def __init__(
212221
self.label = Content.from_text(label)
213222
self.variant = variant
214223
self.action = action
224+
self.compact = compact
215225
self.active_effect_duration = 0.2
216226
"""Amount of time in seconds the button 'press' animation lasts."""
217227

src/textual/widgets/_input.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,9 +177,19 @@ class Input(ScrollView):
177177
height: 3;
178178
scrollbar-size-horizontal: 0;
179179
180+
&.-textual-compact {
181+
border: none !important;
182+
height: 1;
183+
padding: 0;
184+
&.-invalid {
185+
background-tint: $error 20%;
186+
}
187+
}
188+
180189
&:focus {
181-
border: tall $border;
190+
border: tall $border;
182191
background-tint: $foreground 5%;
192+
183193
}
184194
&>.input--cursor {
185195
background: $input-cursor-background;
@@ -253,6 +263,8 @@ def cursor_position(self, position: int) -> None:
253263
"""The maximum length of the input, in characters."""
254264
valid_empty = var(False)
255265
"""Empty values should pass validation."""
266+
compact = reactive(False, toggle_class="-textual-compact")
267+
"""Make the input compact (without borders)."""
256268

257269
@dataclass
258270
class Changed(Message):
@@ -342,6 +354,7 @@ def __init__(
342354
classes: str | None = None,
343355
disabled: bool = False,
344356
tooltip: RenderableType | None = None,
357+
compact: bool = False,
345358
) -> None:
346359
"""Initialise the `Input` widget.
347360
@@ -366,6 +379,7 @@ def __init__(
366379
classes: Optional initial classes for the widget.
367380
disabled: Whether the input is disabled or not.
368381
tooltip: Optional tooltip.
382+
compact: Enable compact style (without borders).
369383
"""
370384
super().__init__(name=name, id=id, classes=classes, disabled=disabled)
371385

@@ -431,6 +445,8 @@ def __init__(
431445
if tooltip is not None:
432446
self.tooltip = tooltip
433447

448+
self.compact = compact
449+
434450
self.select_on_focus = select_on_focus
435451

436452
def _position_to_cell(self, position: int) -> int:

src/textual/widgets/_option_list.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,13 @@ class OptionList(ScrollView, can_focus=True):
139139
border: tall $border-blurred;
140140
padding: 0 1;
141141
background: $surface;
142+
&.-textual-compact {
143+
border: none !important;
144+
padding: 0;
145+
& > .option-list--option {
146+
padding: 0;
147+
}
148+
}
142149
& > .option-list--option-highlighted {
143150
color: $block-cursor-blurred-foreground;
144151
background: $block-cursor-blurred-background;
@@ -192,6 +199,9 @@ class OptionList(ScrollView, can_focus=True):
192199
_mouse_hovering_over: reactive[int | None] = reactive(None)
193200
"""The index of the option under the mouse or `None`."""
194201

202+
compact: reactive[bool] = reactive(False, toggle_class="-textual-compact")
203+
"""Enable compact display?"""
204+
195205
class OptionMessage(Message):
196206
"""Base class for all option messages."""
197207

@@ -252,6 +262,7 @@ def __init__(
252262
classes: str | None = None,
253263
disabled: bool = False,
254264
markup: bool = True,
265+
compact: bool = False,
255266
):
256267
"""Initialize an OptionList.
257268
@@ -262,9 +273,11 @@ def __init__(
262273
classes: Initial CSS classes.
263274
disabled: Disable the widget?
264275
markup: Strips should be rendered as content markup if `True`, or plain text if `False`.
276+
compact: Enable compact style?
265277
"""
266278
super().__init__(name=name, id=id, classes=classes, disabled=disabled)
267279
self._markup = markup
280+
self.compact = compact
268281
self._options: list[Option] = []
269282
"""List of options."""
270283
self._id_to_option: dict[str, Option] = {}

src/textual/widgets/_radio_set.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from textual.containers import VerticalScroll
1313
from textual.events import Click, Mount
1414
from textual.message import Message
15-
from textual.reactive import var
15+
from textual.reactive import reactive, var
1616
from textual.widgets._radio_button import RadioButton
1717

1818

@@ -32,14 +32,20 @@ class RadioSet(VerticalScroll, can_focus=True, can_focus_children=False):
3232
RadioSet {
3333
border: tall $border-blurred;
3434
background: $surface;
35-
padding: 0 1;
35+
padding: 0 1;
3636
height: auto;
37-
width: auto;
37+
width: 1fr;
38+
39+
&.-textual-compact {
40+
border: none !important;
41+
padding: 0;
42+
}
3843
3944
& > RadioButton {
4045
background: transparent;
4146
border: none;
4247
padding: 0;
48+
width: 1fr;
4349
4450
& > .toggle--button {
4551
color: $panel-darken-2;
@@ -87,6 +93,9 @@ class RadioSet(VerticalScroll, can_focus=True, can_focus_children=False):
8793
_selected: var[int | None] = var[Optional[int]](None)
8894
"""The index of the currently-selected radio button."""
8995

96+
compact: reactive[bool] = reactive(False, toggle_class="-textual-compact")
97+
"""Enable compact display?"""
98+
9099
@rich.repr.auto
91100
class Changed(Message):
92101
"""Posted when the pressed button in the set changes.
@@ -133,6 +142,7 @@ def __init__(
133142
classes: str | None = None,
134143
disabled: bool = False,
135144
tooltip: RenderableType | None = None,
145+
compact: bool = False,
136146
) -> None:
137147
"""Initialise the radio set.
138148
@@ -143,6 +153,7 @@ def __init__(
143153
classes: The CSS classes of the radio set.
144154
disabled: Whether the radio set is disabled or not.
145155
tooltip: Optional tooltip.
156+
compact: Enable compact radio set style
146157
147158
Note:
148159
When a `str` label is provided, a
@@ -163,6 +174,7 @@ def __init__(
163174
)
164175
if tooltip is not None:
165176
self.tooltip = tooltip
177+
self.compact = compact
166178

167179
def _on_mount(self, _: Mount) -> None:
168180
"""Perform some processing once mounted in the DOM."""

0 commit comments

Comments
 (0)