Skip to content

Commit 472e94a

Browse files
authored
Merge branch 'main' into themes
2 parents 05a6314 + d8ba63d commit 472e94a

File tree

12 files changed

+209
-15
lines changed

12 files changed

+209
-15
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,12 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
1111

1212
- Fixed duplicated key displays in the help panel https://github.com/Textualize/textual/issues/5037
1313

14+
### Added
15+
16+
- Added `can_focus` and `can_focus_children` parameters to scrollable container types. https://github.com/Textualize/textual/pull/5226
17+
- Added `textual.lazy.Reveal` https://github.com/Textualize/textual/pull/5226
18+
- Added `Screen.action_blur` https://github.com/Textualize/textual/pull/5226
19+
1420
## [0.85.2] - 2024-11-02
1521

1622
- Fixed broken focus-within https://github.com/Textualize/textual/pull/5190

src/textual/app.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -761,6 +761,9 @@ def __init__(
761761

762762
self._hover_effects_timer: Timer | None = None
763763

764+
self._css_update_count: int = 0
765+
"""Incremented when CSS is invalidated."""
766+
764767
if self.ENABLE_COMMAND_PALETTE:
765768
for _key, binding in self._bindings:
766769
if binding.action in {"command_palette", "app.command_palette"}:
@@ -1243,17 +1246,24 @@ def _watch_theme(self, theme_name: str) -> None:
12431246
self.set_class(dark, "-dark-mode", update=False)
12441247
self.set_class(not dark, "-light-mode", update=False)
12451248
self._refresh_truecolor_filter(self.ansi_theme)
1249+
self._invalidate_css()
12461250
self.call_next(self.refresh_css)
12471251
self.call_next(self.theme_changed_signal.publish, theme)
12481252

1253+
def _invalidate_css(self) -> None:
1254+
"""Invalidate CSS, so it will be refreshed."""
1255+
self._css_update_count += 1
1256+
12491257
def watch_ansi_theme_dark(self, theme: TerminalTheme) -> None:
12501258
if self.current_theme.dark:
12511259
self._refresh_truecolor_filter(theme)
1260+
self._invalidate_css()
12521261
self.call_next(self.refresh_css)
12531262

12541263
def watch_ansi_theme_light(self, theme: TerminalTheme) -> None:
12551264
if not self.current_theme.dark:
12561265
self._refresh_truecolor_filter(theme)
1266+
self._invalidate_css()
12571267
self.call_next(self.refresh_css)
12581268

12591269
@property
@@ -2283,7 +2293,9 @@ def _init_mode(self, mode: str) -> AwaitMount:
22832293
screen, await_mount = self._get_screen(new_screen)
22842294
stack.append(screen)
22852295
self._load_screen_css(screen)
2286-
self.refresh_css()
2296+
if screen._css_update_count != self._css_update_count:
2297+
self.refresh_css()
2298+
22872299
screen.post_message(events.ScreenResume())
22882300
else:
22892301
# Mode is not defined
@@ -2328,7 +2340,8 @@ def switch_mode(self, mode: str) -> AwaitMount:
23282340
await_mount = AwaitMount(self.screen, [])
23292341

23302342
self._current_mode = mode
2331-
self.refresh_css()
2343+
if self.screen._css_update_count != self._css_update_count:
2344+
self.refresh_css()
23322345
self.screen._screen_resized(self.size)
23332346
self.screen.post_message(events.ScreenResume())
23342347

@@ -3445,6 +3458,7 @@ def refresh_css(self, animate: bool = True) -> None:
34453458
stylesheet.update(self.app, animate=animate)
34463459
try:
34473460
self.screen._refresh_layout(self.size)
3461+
self.screen._css_update_count = self._css_update_count
34483462
except ScreenError:
34493463
pass
34503464
# The other screens in the stack will need to know about some style
@@ -3453,6 +3467,7 @@ def refresh_css(self, animate: bool = True) -> None:
34533467
for screen in self.screen_stack:
34543468
if screen != self.screen:
34553469
stylesheet.update(screen, animate=animate)
3470+
screen._css_update_count = self._css_update_count
34563471

34573472
def _display(self, screen: Screen, renderable: RenderableType | None) -> None:
34583473
"""Display a renderable within a sync.

src/textual/containers.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,40 @@ class ScrollableContainer(Widget, can_focus=True, inherit_bindings=False):
7373
| ctrl+pagedown | Scroll right one page, if horizontal scrolling is available. |
7474
"""
7575

76+
def __init__(
77+
self,
78+
*children: Widget,
79+
name: str | None = None,
80+
id: str | None = None,
81+
classes: str | None = None,
82+
disabled: bool = False,
83+
can_focus: bool | None = None,
84+
can_focus_children: bool | None = None,
85+
) -> None:
86+
"""
87+
88+
Args:
89+
*children: Child widgets.
90+
name: The name of the widget.
91+
id: The ID of the widget in the DOM.
92+
classes: The CSS classes for the widget.
93+
disabled: Whether the widget is disabled or not.
94+
can_focus: Can this container be focused?
95+
can_focus_children: Can this container's children be focused?
96+
"""
97+
98+
super().__init__(
99+
*children,
100+
name=name,
101+
id=id,
102+
classes=classes,
103+
disabled=disabled,
104+
)
105+
if can_focus is not None:
106+
self.can_focus = can_focus
107+
if can_focus_children is not None:
108+
self.can_focus_children = can_focus_children
109+
76110

77111
class Vertical(Widget, inherit_bindings=False):
78112
"""An expanding container with vertical layout and no scrollbars."""

src/textual/demo/demo_app.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from __future__ import annotations
2+
13
from textual.app import App
24
from textual.binding import Binding
35
from textual.demo.home import HomeScreen

src/textual/demo/home.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from __future__ import annotations
2+
13
import asyncio
24
from importlib.metadata import version
35

src/textual/demo/page.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from __future__ import annotations
2+
13
import inspect
24

35
from rich.syntax import Syntax

src/textual/demo/projects.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from __future__ import annotations
2+
13
from dataclasses import dataclass
24

35
from textual import events, on

src/textual/demo/widgets.py

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from __future__ import annotations
2+
13
import csv
24
import io
35
from math import sin
@@ -6,7 +8,7 @@
68
from rich.table import Table
79
from rich.traceback import Traceback
810

9-
from textual import containers
11+
from textual import containers, lazy
1012
from textual.app import ComposeResult
1113
from textual.binding import Binding
1214
from textual.demo.data import COUNTRIES
@@ -439,19 +441,21 @@ class WidgetsScreen(PageScreen):
439441
CSS = """
440442
WidgetsScreen {
441443
align-horizontal: center;
442-
& > VerticalScroll > * {
443-
&:last-of-type { margin-bottom: 2; }
444-
&:even { background: $boost; }
445-
padding-bottom: 1;
446-
}
444+
& > VerticalScroll {
445+
scrollbar-gutter: stable;
446+
&> * {
447+
&:last-of-type { margin-bottom: 2; }
448+
&:even { background: $boost; }
449+
padding-bottom: 1;
450+
}
451+
}
447452
}
448453
"""
449454

450-
BINDINGS = [Binding("escape", "unfocus", "Unfocus any focused widget", show=False)]
455+
BINDINGS = [Binding("escape", "blur", "Unfocus any focused widget", show=False)]
451456

452457
def compose(self) -> ComposeResult:
453-
with containers.VerticalScroll() as container:
454-
container.can_focus = False
458+
with lazy.Reveal(containers.VerticalScroll(can_focus=False)):
455459
yield Markdown(WIDGETS_MD, classes="column")
456460
yield Buttons()
457461
yield Checkboxes()
@@ -461,6 +465,3 @@ def compose(self) -> ComposeResult:
461465
yield Logs()
462466
yield Sparklines()
463467
yield Footer()
464-
465-
def action_unfocus(self) -> None:
466-
self.set_focus(None)

src/textual/lazy.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
from __future__ import annotations
66

7+
from functools import partial
8+
79
from textual.widget import Widget
810

911

@@ -61,3 +63,85 @@ async def mount() -> None:
6163
await self.remove()
6264

6365
self.call_after_refresh(mount)
66+
67+
68+
class Reveal(Widget):
69+
DEFAULT_CSS = """
70+
Reveal {
71+
display: none;
72+
}
73+
"""
74+
75+
def __init__(self, widget: Widget, delay: float = 1 / 60) -> None:
76+
"""Similar to [Lazy][textual.lazy.Lazy], but also displays *children* sequentially.
77+
78+
The first frame will display the first child with all other children hidden.
79+
The remaining children will be displayed 1-by-1, over as may frames are required.
80+
81+
This is useful when you have so many child widgets that there is a noticeable delay before
82+
you see anything. By mounting the children over several frames, the user will feel that
83+
something is happening.
84+
85+
Example:
86+
```python
87+
def compose(self) -> ComposeResult:
88+
with lazy.Reveal(containers.VerticalScroll(can_focus=False)):
89+
yield Markdown(WIDGETS_MD, classes="column")
90+
yield Buttons()
91+
yield Checkboxes()
92+
yield Datatables()
93+
yield Inputs()
94+
yield ListViews()
95+
yield Logs()
96+
yield Sparklines()
97+
yield Footer()
98+
```
99+
100+
Args:
101+
widget: A widget that should be mounted after a refresh.
102+
delay: A (short) delay between mounting widgets.
103+
"""
104+
self._replace_widget = widget
105+
self._delay = delay
106+
super().__init__()
107+
108+
@classmethod
109+
def _reveal(cls, parent: Widget, delay: float = 1 / 60) -> None:
110+
"""Reveal children lazily.
111+
112+
Args:
113+
parent: The parent widget.
114+
delay: A delay between reveals.
115+
"""
116+
117+
def check_children() -> None:
118+
"""Check for un-displayed children."""
119+
iter_children = iter(parent.children)
120+
for child in iter_children:
121+
if not child.display:
122+
child.display = True
123+
break
124+
for child in iter_children:
125+
if not child.display:
126+
parent.set_timer(
127+
delay, partial(parent.call_after_refresh, check_children)
128+
)
129+
break
130+
131+
check_children()
132+
133+
def compose_add_child(self, widget: Widget) -> None:
134+
widget.display = False
135+
self._replace_widget.compose_add_child(widget)
136+
137+
async def mount_composed_widgets(self, widgets: list[Widget]) -> None:
138+
parent = self.parent
139+
if parent is None:
140+
return
141+
assert isinstance(parent, Widget)
142+
143+
if self._replace_widget.children:
144+
self._replace_widget.children[0].display = True
145+
await parent.mount(self._replace_widget, after=self)
146+
await self.remove()
147+
self._reveal(self._replace_widget, self._delay)

src/textual/screen.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,9 @@ def __init__(
266266
self.bindings_updated_signal: Signal[Screen] = Signal(self, "bindings_updated")
267267
"""A signal published when the bindings have been updated"""
268268

269+
self._css_update_count = -1
270+
"""Track updates to CSS."""
271+
269272
@property
270273
def is_modal(self) -> bool:
271274
"""Is the screen modal?"""
@@ -779,6 +782,10 @@ def action_minimize(self) -> None:
779782
"""Action to minimize the currently maximized widget."""
780783
self.minimize()
781784

785+
def action_blur(self) -> None:
786+
"""Action to remove focus (if set)."""
787+
self.set_focus(None)
788+
782789
def _reset_focus(
783790
self, widget: Widget, avoiding: list[Widget] | None = None
784791
) -> None:

0 commit comments

Comments
 (0)