-
Notifications
You must be signed in to change notification settings - Fork 741
/
widget.py
3272 lines (2773 loc) · 109 KB
/
widget.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""
The base class for widgets.
"""
from __future__ import annotations
from asyncio import wait
from collections import Counter
from fractions import Fraction
from itertools import islice
from operator import attrgetter
from types import TracebackType
from typing import (
TYPE_CHECKING,
ClassVar,
Collection,
Generator,
Iterable,
NamedTuple,
Sequence,
TypeVar,
cast,
overload,
)
import rich.repr
from rich.console import (
Console,
ConsoleOptions,
ConsoleRenderable,
JustifyMethod,
RenderableType,
RenderResult,
RichCast,
)
from rich.measure import Measurement
from rich.segment import Segment
from rich.style import Style
from rich.text import Text
from rich.traceback import Traceback
from typing_extensions import Self
from . import constants, errors, events, messages
from ._animator import DEFAULT_EASING, Animatable, BoundAnimator, EasingFunction
from ._arrange import DockArrangeResult, arrange
from ._asyncio import create_task
from ._cache import FIFOCache
from ._compose import compose
from ._context import NoActiveAppError, active_app
from ._easing import DEFAULT_SCROLL_EASING
from ._layout import Layout
from ._segment_tools import align_lines
from ._styles_cache import StylesCache
from .actions import SkipAction
from .await_remove import AwaitRemove
from .box_model import BoxModel
from .css.query import NoMatches, WrongType
from .css.scalar import ScalarOffset
from .dom import DOMNode, NoScreen
from .geometry import NULL_REGION, NULL_SPACING, Offset, Region, Size, Spacing, clamp
from .layouts.vertical import VerticalLayout
from .message import Message
from .messages import CallbackType
from .reactive import Reactive
from .render import measure
from .strip import Strip
from .walk import walk_depth_first
if TYPE_CHECKING:
from .app import App, ComposeResult
from .message_pump import MessagePump
from .scrollbar import (
ScrollBar,
ScrollBarCorner,
ScrollDown,
ScrollLeft,
ScrollRight,
ScrollTo,
ScrollUp,
)
_JUSTIFY_MAP: dict[str, JustifyMethod] = {
"start": "left",
"end": "right",
"justify": "full",
}
class AwaitMount:
"""An *optional* awaitable returned by [mount][textual.widget.Widget.mount] and [mount_all][textual.widget.Widget.mount_all].
Example:
```python
await self.mount(Static("foo"))
```
"""
def __init__(self, parent: Widget, widgets: Sequence[Widget]) -> None:
self._parent = parent
self._widgets = widgets
async def __call__(self) -> None:
"""Allows awaiting via a call operation."""
await self
def __await__(self) -> Generator[None, None, None]:
async def await_mount() -> None:
if self._widgets:
aws = [
create_task(widget._mounted_event.wait(), name="await mount")
for widget in self._widgets
]
if aws:
await wait(aws)
self._parent.refresh(layout=True)
return await_mount().__await__()
class _Styled:
"""Apply a style to a renderable.
Args:
renderable: Any renderable.
style: A style to apply across the entire renderable.
"""
def __init__(
self, renderable: "ConsoleRenderable", style: Style, link_style: Style | None
) -> None:
self.renderable = renderable
self.style = style
self.link_style = link_style
def __rich_console__(
self, console: "Console", options: "ConsoleOptions"
) -> "RenderResult":
style = console.get_style(self.style)
result_segments = console.render(self.renderable, options)
_Segment = Segment
if style:
apply = style.__add__
result_segments = (
_Segment(text, apply(_style), None)
for text, _style, control in result_segments
)
link_style = self.link_style
if link_style:
result_segments = (
_Segment(
text,
(
style
if style._meta is None
else (style + link_style if "@click" in style.meta else style)
),
control,
)
for text, style, control in result_segments
if style is not None
)
return result_segments
def __rich_measure__(
self, console: "Console", options: "ConsoleOptions"
) -> Measurement:
return Measurement.get(console, options, self.renderable)
class _RenderCache(NamedTuple):
"""Stores results of a previous render."""
size: Size
"""The size of the render."""
lines: list[Strip]
"""Contents of the render."""
class WidgetError(Exception):
"""Base widget error."""
class MountError(WidgetError):
"""Error raised when there was a problem with the mount request."""
class PseudoClasses(NamedTuple):
"""Used for render/render_line based widgets that use caching. This structure can be used as a
cache-key."""
enabled: bool
"""Is 'enabled' applied?"""
focus: bool
"""Is 'focus' applied?"""
hover: bool
"""Is 'hover' applied?"""
class _BorderTitle:
"""Descriptor to set border titles."""
def __set_name__(self, owner: Widget, name: str) -> None:
# The private name where we store the real data.
self._internal_name = f"_{name}"
def __set__(self, obj: Widget, title: str | Text | None) -> None:
"""Setting a title accepts a str, Text, or None."""
if title is None:
setattr(obj, self._internal_name, None)
else:
# We store the title as Text
new_title = obj.render_str(title)
new_title.expand_tabs(4)
new_title = new_title.split()[0]
setattr(obj, self._internal_name, new_title)
obj.refresh()
def __get__(self, obj: Widget, objtype: type[Widget] | None = None) -> str | None:
"""Getting a title will return None or a str as console markup."""
title: Text | None = getattr(obj, self._internal_name, None)
if title is None:
return None
# If we have a title, convert from Text to console markup
return title.markup
@rich.repr.auto
class Widget(DOMNode):
"""
A Widget is the base class for Textual widgets.
See also [static][textual.widgets._static.Static] for starting point for your own widgets.
"""
DEFAULT_CSS = """
Widget{
scrollbar-background: $panel-darken-1;
scrollbar-background-hover: $panel-darken-2;
scrollbar-background-active: $panel-darken-3;
scrollbar-color: $primary-lighten-1;
scrollbar-color-active: $warning-darken-1;
scrollbar-color-hover: $primary-lighten-1;
scrollbar-corner-color: $panel-darken-1;
scrollbar-size-vertical: 2;
scrollbar-size-horizontal: 1;
link-background:;
link-color: $text;
link-style: underline;
link-hover-background: $accent;
link-hover-color: $text;
link-hover-style: bold not underline;
}
"""
COMPONENT_CLASSES: ClassVar[set[str]] = set()
can_focus: bool = False
"""Widget may receive focus."""
can_focus_children: bool = True
"""Widget's children may receive focus."""
expand: Reactive[bool] = Reactive(False)
"""Rich renderable may expand beyond optimal size."""
shrink: Reactive[bool] = Reactive(True)
"""Rich renderable may shrink below optimal size."""
auto_links: Reactive[bool] = Reactive(True)
"""Widget will highlight links automatically."""
disabled: Reactive[bool] = Reactive(False)
"""Is the widget disabled? Disabled widgets can not be interacted with, and are typically styled to look dimmer."""
hover_style: Reactive[Style] = Reactive(Style, repaint=False)
"""The current hover style (style under the mouse cursor). Read only."""
highlight_link_id: Reactive[str] = Reactive("")
"""The currently highlighted link id. Read only."""
def __init__(
self,
*children: Widget,
name: str | None = None,
id: str | None = None,
classes: str | None = None,
disabled: bool = False,
) -> None:
"""Initialize a Widget.
Args:
*children: Child widgets.
name: The name of the widget.
id: The ID of the widget in the DOM.
classes: The CSS classes for the widget.
disabled: Whether the widget is disabled or not.
"""
self._size = Size(0, 0)
self._container_size = Size(0, 0)
self._layout_required = False
self._repaint_required = False
self._scroll_required = False
self._default_layout = VerticalLayout()
self._animate: BoundAnimator | None = None
self.highlight_style: Style | None = None
self._vertical_scrollbar: ScrollBar | None = None
self._horizontal_scrollbar: ScrollBar | None = None
self._scrollbar_corner: ScrollBarCorner | None = None
self._border_title: Text | None = None
self._border_subtitle: Text | None = None
self._render_cache = _RenderCache(Size(0, 0), [])
# Regions which need to be updated (in Widget)
self._dirty_regions: set[Region] = set()
# Regions which need to be transferred from cache to screen
self._repaint_regions: set[Region] = set()
# Cache the auto content dimensions
# TODO: add mechanism to explicitly clear this
self._content_width_cache: tuple[object, int] = (None, 0)
self._content_height_cache: tuple[object, int] = (None, 0)
self._arrangement_cache: FIFOCache[
tuple[Size, int], DockArrangeResult
] = FIFOCache(4)
self._styles_cache = StylesCache()
self._rich_style_cache: dict[str, tuple[Style, Style]] = {}
self._stabilize_scrollbar: tuple[Size, str, str] | None = None
"""Used to prevent scrollbar logic getting stuck in an infinite loop."""
self._tooltip: RenderableType | None = None
"""The tooltip content."""
self._absolute_offset: Offset | None = None
"""Force an absolute offset for the widget (used by tooltips)."""
super().__init__(
name=name,
id=id,
classes=self.DEFAULT_CLASSES if classes is None else classes,
)
if self in children:
raise WidgetError("A widget can't be its own parent")
for child in children:
if not isinstance(child, Widget):
raise TypeError(
f"Widget positional arguments must be Widget subclasses; not {child!r}"
)
self._add_children(*children)
self.disabled = disabled
virtual_size: Reactive[Size] = Reactive(Size(0, 0), layout=True)
"""The virtual (scrollable) [size][textual.geometry.Size] of the widget."""
has_focus: Reactive[bool] = Reactive(False, repaint=False)
"""Does this widget have focus? Read only."""
mouse_over: Reactive[bool] = Reactive(False, repaint=False)
"""Is the mouse over this widget? Read only."""
scroll_x: Reactive[float] = Reactive(0.0, repaint=False, layout=False)
"""The scroll position on the X axis."""
scroll_y: Reactive[float] = Reactive(0.0, repaint=False, layout=False)
"""The scroll position on the Y axis."""
scroll_target_x = Reactive(0.0, repaint=False)
scroll_target_y = Reactive(0.0, repaint=False)
show_vertical_scrollbar: Reactive[bool] = Reactive(False, layout=True)
"""Show a horizontal scrollbar?"""
show_horizontal_scrollbar: Reactive[bool] = Reactive(False, layout=True)
"""Show a horizontal scrollbar?"""
border_title: str | Text | None = _BorderTitle() # type: ignore
"""A title to show in the top border (if there is one)."""
border_subtitle: str | Text | None = _BorderTitle() # type: ignore
"""A title to show in the bottom border (if there is one)."""
@property
def siblings(self) -> list[Widget]:
"""Get the widget's siblings (self is removed from the return list).
Returns:
A list of siblings.
"""
parent = self.parent
if parent is not None:
siblings = list(parent._nodes)
siblings.remove(self)
return siblings
else:
return []
@property
def visible_siblings(self) -> list[Widget]:
"""A list of siblings which will be shown.
Returns:
List of siblings.
"""
siblings = [
widget for widget in self.siblings if widget.visible and widget.display
]
return siblings
@property
def allow_vertical_scroll(self) -> bool:
"""Check if vertical scroll is permitted.
May be overridden if you want different logic regarding allowing scrolling.
"""
return self.is_scrollable and self.show_vertical_scrollbar
@property
def allow_horizontal_scroll(self) -> bool:
"""Check if horizontal scroll is permitted.
May be overridden if you want different logic regarding allowing scrolling.
"""
return self.is_scrollable and self.show_horizontal_scrollbar
@property
def _allow_scroll(self) -> bool:
"""Check if both axis may be scrolled.
Returns:
True if horizontal and vertical scrolling is enabled.
"""
return self.is_scrollable and (
self.allow_horizontal_scroll or self.allow_vertical_scroll
)
@property
def offset(self) -> Offset:
"""Widget offset from origin.
Returns:
Relative offset.
"""
return self.styles.offset.resolve(self.size, self.app.size)
@offset.setter
def offset(self, offset: tuple[int, int]) -> None:
self.styles.offset = ScalarOffset.from_offset(offset)
@property
def tooltip(self) -> RenderableType | None:
"""Tooltip for the widget, or `None` for no tooltip."""
return self._tooltip
@tooltip.setter
def tooltip(self, tooltip: RenderableType | None):
self._tooltip = tooltip
try:
self.screen._update_tooltip(self)
except NoScreen:
pass
def __enter__(self) -> Self:
"""Use as context manager when composing."""
self.app._compose_stacks[-1].append(self)
return self
def __exit__(
self,
exc_type: type[BaseException] | None,
exc_val: BaseException | None,
exc_tb: TracebackType | None,
) -> None:
"""Exit compose context manager."""
compose_stack = self.app._compose_stacks[-1]
composed = compose_stack.pop()
if compose_stack:
compose_stack[-1].compose_add_child(composed)
else:
self.app._composed[-1].append(composed)
ExpectType = TypeVar("ExpectType", bound="Widget")
@overload
def get_child_by_id(self, id: str) -> Widget:
...
@overload
def get_child_by_id(self, id: str, expect_type: type[ExpectType]) -> ExpectType:
...
def get_child_by_id(
self, id: str, expect_type: type[ExpectType] | None = None
) -> ExpectType | Widget:
"""Return the first child (immediate descendent) of this node with the given ID.
Args:
id: The ID of the child.
expect_type: Require the object be of the supplied type, or None for any type.
Returns:
The first child of this node with the ID.
Raises:
NoMatches: if no children could be found for this ID
WrongType: if the wrong type was found.
"""
child = self._nodes._get_by_id(id)
if child is None:
raise NoMatches(f"No child found with id={id!r}")
if expect_type is None:
return child
if not isinstance(child, expect_type):
raise WrongType(
f"Child with id={id!r} is wrong type; expected {expect_type}, got"
f" {type(child)}"
)
return child
@overload
def get_widget_by_id(self, id: str) -> Widget:
...
@overload
def get_widget_by_id(self, id: str, expect_type: type[ExpectType]) -> ExpectType:
...
def get_widget_by_id(
self, id: str, expect_type: type[ExpectType] | None = None
) -> ExpectType | Widget:
"""Return the first descendant widget with the given ID.
Performs a depth-first search rooted at this widget.
Args:
id: The ID to search for in the subtree.
expect_type: Require the object be of the supplied type, or None for any type.
Returns:
The first descendant encountered with this ID.
Raises:
NoMatches: if no children could be found for this ID.
WrongType: if the wrong type was found.
"""
# We use Widget as a filter_type so that the inferred type of child is Widget.
for child in walk_depth_first(self, filter_type=Widget):
try:
if expect_type is None:
return child.get_child_by_id(id)
else:
return child.get_child_by_id(id, expect_type=expect_type)
except NoMatches:
pass
except WrongType as exc:
raise WrongType(
f"Descendant with id={id!r} is wrong type; expected {expect_type},"
f" got {type(child)}"
) from exc
raise NoMatches(f"No descendant found with id={id!r}")
def get_child_by_type(self, expect_type: type[ExpectType]) -> ExpectType:
"""Get a child of a give type.
Args:
expect_type: The type of the expected child.
Raises:
NoMatches: If no valid child is found.
Returns:
A widget.
"""
for child in self._nodes:
# We want the child with the exact type (not subclasses)
if type(child) is expect_type:
assert isinstance(child, expect_type)
return child
raise NoMatches(f"No immediate child of type {expect_type}; {self._nodes}")
def get_component_rich_style(self, name: str, *, partial: bool = False) -> Style:
"""Get a *Rich* style for a component.
Args:
name: Name of component.
partial: Return a partial style (not combined with parent).
Returns:
A Rich style object.
"""
if name not in self._rich_style_cache:
component_styles = self.get_component_styles(name)
style = component_styles.rich_style
partial_style = component_styles.partial_rich_style
self._rich_style_cache[name] = (style, partial_style)
style, partial_style = self._rich_style_cache[name]
return partial_style if partial else style
def render_str(self, text_content: str | Text) -> Text:
"""Convert str in to a Text object.
If you pass in an existing Text object it will be returned unaltered.
Args:
text_content: Text or str.
Returns:
A text object.
"""
text = (
Text.from_markup(text_content)
if isinstance(text_content, str)
else text_content
)
return text
def _arrange(self, size: Size) -> DockArrangeResult:
"""Arrange children.
Args:
size: Size of container.
Returns:
Widget locations.
"""
assert self.is_container
cache_key = (size, self._nodes._updates)
cached_result = self._arrangement_cache.get(cache_key)
if cached_result is not None:
return cached_result
arrangement = self._arrangement_cache[cache_key] = arrange(
self, self._nodes, size, self.screen.size
)
return arrangement
def _clear_arrangement_cache(self) -> None:
"""Clear arrangement cache, forcing a new arrange operation."""
self._arrangement_cache.clear()
self._stabilize_scrollbar = None
def _get_virtual_dom(self) -> Iterable[Widget]:
"""Get widgets not part of the DOM.
Returns:
An iterable of Widgets.
"""
if self._horizontal_scrollbar is not None:
yield self._horizontal_scrollbar
if self._vertical_scrollbar is not None:
yield self._vertical_scrollbar
if self._scrollbar_corner is not None:
yield self._scrollbar_corner
def _find_mount_point(self, spot: int | str | "Widget") -> tuple["Widget", int]:
"""Attempt to locate the point where the caller wants to mount something.
Args:
spot: The spot to find.
Returns:
The parent and the location in its child list.
Raises:
MountError: If there was an error finding where to mount a widget.
The rules of this method are:
- Given an ``int``, parent is ``self`` and location is the integer value.
- Given a ``Widget``, parent is the widget's parent and location is
where the widget is found in the parent's ``children``. If it
can't be found a ``MountError`` will be raised.
- Given a string, it is used to perform a ``query_one`` and then the
result is used as if a ``Widget`` had been given.
"""
# A numeric location means at that point in our child list.
if isinstance(spot, int):
return self, spot
# If we've got a string, that should be treated like a query that
# can be passed to query_one. So let's use that to get a widget to
# work on.
if isinstance(spot, str):
spot = self.query_one(spot, Widget)
# At this point we should have a widget, either because we got given
# one, or because we pulled one out of the query. First off, does it
# have a parent? There's no way we can use it as a sibling to make
# mounting decisions if it doesn't have a parent.
if spot.parent is None:
raise MountError(
f"Unable to find relative location of {spot!r} because it has no parent"
)
# We've got a widget. It has a parent. It has (zero or more)
# children. We should be able to go looking for the widget's
# location amongst its parent's children.
try:
return cast("Widget", spot.parent), spot.parent._nodes.index(spot)
except ValueError:
raise MountError(f"{spot!r} is not a child of {self!r}") from None
def mount(
self,
*widgets: Widget,
before: int | str | Widget | None = None,
after: int | str | Widget | None = None,
) -> AwaitMount:
"""Mount widgets below this widget (making this widget a container).
Args:
*widgets: The widget(s) to mount.
before: Optional location to mount before. An `int` is the index
of the child to mount before, a `str` is a `query_one` query to
find the widget to mount before.
after: Optional location to mount after. An `int` is the index
of the child to mount after, a `str` is a `query_one` query to
find the widget to mount after.
Returns:
An awaitable object that waits for widgets to be mounted.
Raises:
MountError: If there is a problem with the mount request.
Note:
Only one of ``before`` or ``after`` can be provided. If both are
provided a ``MountError`` will be raised.
"""
# Check for duplicate IDs in the incoming widgets
ids_to_mount = [widget.id for widget in widgets if widget.id is not None]
unique_ids = set(ids_to_mount)
num_unique_ids = len(unique_ids)
num_widgets_with_ids = len(ids_to_mount)
if num_unique_ids != num_widgets_with_ids:
counter = Counter(widget.id for widget in widgets)
for widget_id, count in counter.items():
if count > 1:
raise MountError(
f"Tried to insert {count!r} widgets with the same ID {widget_id!r}. "
"Widget IDs must be unique."
)
# Saying you want to mount before *and* after something is an error.
if before is not None and after is not None:
raise MountError(
"Only one of `before` or `after` can be handled -- not both"
)
# Decide the final resting place depending on what we've been asked
# to do.
insert_before: int | None = None
insert_after: int | None = None
if before is not None:
parent, insert_before = self._find_mount_point(before)
elif after is not None:
parent, insert_after = self._find_mount_point(after)
else:
parent = self
mounted = self.app._register(
parent, *widgets, before=insert_before, after=insert_after
)
await_mount = AwaitMount(self, mounted)
self.call_next(await_mount)
return await_mount
def mount_all(
self,
widgets: Iterable[Widget],
*,
before: int | str | Widget | None = None,
after: int | str | Widget | None = None,
) -> AwaitMount:
"""Mount widgets from an iterable.
Args:
widgets: An iterable of widgets.
before: Optional location to mount before. An `int` is the index
of the child to mount before, a `str` is a `query_one` query to
find the widget to mount before.
after: Optional location to mount after. An `int` is the index
of the child to mount after, a `str` is a `query_one` query to
find the widget to mount after.
Returns:
An awaitable object that waits for widgets to be mounted.
Raises:
MountError: If there is a problem with the mount request.
Note:
Only one of ``before`` or ``after`` can be provided. If both are
provided a ``MountError`` will be raised.
"""
await_mount = self.mount(*widgets, before=before, after=after)
return await_mount
def move_child(
self,
child: int | Widget,
before: int | Widget | None = None,
after: int | Widget | None = None,
) -> None:
"""Move a child widget within its parent's list of children.
Args:
child: The child widget to move.
before: Child widget or location index to move before.
after: Child widget or location index to move after.
Raises:
WidgetError: If there is a problem with the child or target.
Note:
Only one of `before` or `after` can be provided. If neither
or both are provided a `WidgetError` will be raised.
"""
# One or the other of before or after are required. Can't do
# neither, can't do both.
if before is None and after is None:
raise WidgetError("One of `before` or `after` is required.")
elif before is not None and after is not None:
raise WidgetError("Only one of `before` or `after` can be handled.")
# We short-circuit the no-op, otherwise it will error later down the road.
if child is before or child is after:
return
def _to_widget(child: int | Widget, called: str) -> Widget:
"""Ensure a given child reference is a Widget."""
if isinstance(child, int):
try:
child = self._nodes[child]
except IndexError:
raise WidgetError(
f"An index of {child} for the child to {called} is out of bounds"
) from None
else:
# We got an actual widget, so let's be sure it really is one of
# our children.
try:
_ = self._nodes.index(child)
except ValueError:
raise WidgetError(f"{child!r} is not a child of {self!r}") from None
return child
# Ensure the child and target are widgets.
child = _to_widget(child, "move")
target = _to_widget(
cast("int | Widget", before if after is None else after), "move towards"
)
# At this point we should know what we're moving, and it should be a
# child; where we're moving it to, which should be within the child
# list; and how we're supposed to move it. All that's left is doing
# the right thing.
self._nodes._remove(child)
if before is not None:
self._nodes._insert(self._nodes.index(target), child)
else:
self._nodes._insert(self._nodes.index(target) + 1, child)
# Request a refresh.
self.refresh(layout=True)
def compose(self) -> ComposeResult:
"""Called by Textual to create child widgets.
Extend this to build a UI.
Example:
```python
def compose(self) -> ComposeResult:
yield Header()
yield Container(
Tree(), Viewer()
)
yield Footer()
```
"""
yield from ()
def _post_register(self, app: App) -> None:
"""Called when the instance is registered.
Args:
app: App instance.
"""
# Parse the Widget's CSS
for path, css, tie_breaker in self._get_default_css():
self.app.stylesheet.add_source(
css, path=path, is_default_css=True, tie_breaker=tie_breaker
)
def _get_box_model(
self,
container: Size,
viewport: Size,
width_fraction: Fraction,
height_fraction: Fraction,
) -> BoxModel:
"""Process the box model for this widget.
Args:
container: The size of the container widget (with a layout)
viewport: The viewport size.
width_fraction: A fraction used for 1 `fr` unit on the width dimension.
height_fraction: A fraction used for 1 `fr` unit on the height dimension.
Returns:
The size and margin for this widget.
"""
styles = self.styles
_content_width, _content_height = container
content_width = Fraction(_content_width)
content_height = Fraction(_content_height)
is_border_box = styles.box_sizing == "border-box"
gutter = styles.gutter
margin = styles.margin
is_auto_width = styles.width and styles.width.is_auto
is_auto_height = styles.height and styles.height.is_auto
# Container minus padding and border
content_container = container - gutter.totals
# The container including the content
sizing_container = content_container if is_border_box else container
if styles.width is None:
# No width specified, fill available space
content_width = Fraction(content_container.width - margin.width)
elif is_auto_width:
# When width is auto, we want enough space to always fit the content
content_width = Fraction(
self.get_content_width(
content_container - styles.margin.totals, viewport
)
)
if styles.scrollbar_gutter == "stable" and styles.overflow_x == "auto":
content_width += styles.scrollbar_size_vertical
if (
content_width < content_container.width
and self._has_relative_children_width
):
content_width = Fraction(content_container.width)
else:
# An explicit width
styles_width = styles.width
content_width = styles_width.resolve(
sizing_container - styles.margin.totals, viewport, width_fraction
)
if is_border_box and styles_width.excludes_border:
content_width -= gutter.width
if styles.min_width is not None:
# Restrict to minimum width, if set
min_width = styles.min_width.resolve(
content_container, viewport, width_fraction
)
content_width = max(content_width, min_width)
if styles.max_width is not None:
# Restrict to maximum width, if set
max_width = styles.max_width.resolve(
content_container, viewport, width_fraction
)
if is_border_box:
max_width -= gutter.width
content_width = min(content_width, max_width)
content_width = max(Fraction(0), content_width)
if styles.height is None:
# No height specified, fill the available space
content_height = Fraction(content_container.height - margin.height)
elif is_auto_height:
# Calculate dimensions based on content
content_height = Fraction(
self.get_content_height(content_container, viewport, int(content_width))
)
if styles.scrollbar_gutter == "stable" and styles.overflow_y == "auto":
content_height += styles.scrollbar_size_horizontal
if (
content_height < content_container.height
and self._has_relative_children_height
):
content_height = Fraction(content_container.height)
else:
styles_height = styles.height
# Explicit height set
content_height = styles_height.resolve(
sizing_container - styles.margin.totals, viewport, height_fraction
)