Skip to content

Commit df63030

Browse files
committed
markdowns
1 parent 672daf8 commit df63030

File tree

3 files changed

+187
-59
lines changed

3 files changed

+187
-59
lines changed

src/textual/demo/widgets.py

Lines changed: 130 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,6 @@ class Checkboxes(containers.VerticalGroup):
128128
129129
"""
130130
RADIOSET_MD = """\
131-
132131
### Radio Sets
133132
134133
A *radio set* is a list of mutually exclusive options.
@@ -408,6 +407,108 @@ def update_rich_log(self) -> None:
408407
rich_log.write(traceback, animate=True)
409408

410409

410+
class Markdowns(containers.VerticalGroup):
411+
DEFAULT_CLASSES = "column"
412+
DEFAULT_CSS = """
413+
Markdowns {
414+
#container {
415+
border: tall transparent;
416+
height: 16;
417+
padding: 0 1;
418+
&:focus { border: tall $border; }
419+
&.-maximized { height: 1fr; }
420+
}
421+
#movies {
422+
padding: 0 1;
423+
MarkdownBlock { padding: 0 1 0 0; }
424+
}
425+
}
426+
"""
427+
MD_MD = """\
428+
## Markdown
429+
430+
Display Markdown in your apps with the Markdown widget.
431+
Most of the text on this page is Markdown.
432+
433+
Here's an AI generated Markdown document:
434+
435+
"""
436+
MOVIES_MD = """\
437+
# The Golden Age of Action Cinema: The 1980s
438+
439+
The 1980s marked a transformative era in action cinema, defined by **excessive machismo**, explosive practical effects, and unforgettable one-liners. This decade gave birth to many of Hollywood's most enduring action franchises, from _Die Hard_ to _Rambo_, setting templates that filmmakers still reference today.
440+
441+
## Technical Innovation
442+
443+
Technologically, the 80s represented a sweet spot between practical effects and early CGI. Filmmakers relied heavily on:
444+
445+
* Practical stunts
446+
* Pyrotechnics
447+
* Hand-built models
448+
449+
These elements lent the films a tangible quality that many argue remains superior to modern digital effects.
450+
451+
## The Action Hero Archetype
452+
453+
The quintessential action hero emerged during this period, with key characteristics:
454+
455+
1. Impressive physique
456+
2. Military background
457+
3. Anti-authority attitude
458+
4. Memorable catchphrases
459+
460+
> "I'll be back" - The Terminator (1984)
461+
462+
Heroes like Arnold Schwarzenegger and Sylvester Stallone became global icons. However, the decade also saw more nuanced characters emerge, like Bruce Willis's everyman John McClane in *Die Hard*, and powerful female protagonists like Sigourney Weaver's Ellen Ripley in *Aliens*.
463+
464+
### Political Influence
465+
466+
Cold War politics heavily influenced these films' narratives, with many plots featuring American heroes facing off against Soviet adversaries. This political subtext, combined with themes of individual triumph over bureaucratic systems, perfectly captured the era's zeitgeist.
467+
468+
---
469+
470+
While often dismissed as simple entertainment, 80s action films left an indelible mark on cinema history, influencing everything from filming techniques to narrative structures, and continuing to inspire filmmakers and delight audiences decades later.
471+
472+
"""
473+
474+
def compose(self) -> ComposeResult:
475+
yield Markdown(self.MD_MD)
476+
with containers.VerticalScroll(
477+
id="container", can_focus=True, can_maximize=True
478+
):
479+
yield Markdown(self.MOVIES_MD, id="movies")
480+
481+
482+
class Selects(containers.VerticalGroup):
483+
DEFAULT_CLASSES = "column"
484+
SELECTS_MD = """\
485+
## Selects
486+
487+
Selects (AKA *Combo boxes*), present a list of options in a menu that may be expanded by the user.
488+
"""
489+
HEROS = [
490+
"Arnold Schwarzenegger",
491+
"Brigitte Nielsen",
492+
"Bruce Willis",
493+
"Carl Weathers",
494+
"Chuck Norris",
495+
"Dolph Lundgren",
496+
"Grace Jones",
497+
"Harrison Ford",
498+
"Jean-Claude Van Damme",
499+
"Kurt Russell",
500+
"Linda Hamilton",
501+
"Mel Gibson",
502+
"Michelle Yeoh",
503+
"Sigourney Weaver",
504+
"Sylvester Stallone",
505+
]
506+
507+
def compose(self) -> ComposeResult:
508+
yield Markdown(self.SELECTS_MD)
509+
yield Select.from_values(self.HEROS, prompt="80s action hero")
510+
511+
411512
class Sparklines(containers.VerticalGroup):
412513
"""Demonstrates sparklines."""
413514

@@ -514,6 +615,10 @@ def on_click(self, event: events.Click) -> None:
514615

515616
def on_switch_changed(self, event: Switch.Changed) -> None:
516617
# Don't issue more Changed events
618+
if not event.value:
619+
self.query_one("#textual-dark", Switch).value = True
620+
return
621+
517622
with self.prevent(Switch.Changed):
518623
# Reset all other switches
519624
for switch in self.query("Switch").results(Switch):
@@ -578,10 +683,28 @@ def compose(self) -> ComposeResult:
578683
prompt="Highlight language",
579684
)
580685

581-
yield TextArea(self.DEFAULT_TEXT, show_line_numbers=True)
686+
yield TextArea(self.DEFAULT_TEXT, show_line_numbers=True, language=None)
582687

583688
def on_select_changed(self, event: Select.Changed) -> None:
584-
self.query_one(TextArea).language = (event.value or "").lower()
689+
self.query_one(TextArea).language = (
690+
event.value.lower() if isinstance(event.value, str) else None
691+
)
692+
693+
694+
class YourWidgets(containers.VerticalGroup):
695+
DEFAULT_CLASSES = "column"
696+
YOUR_MD = """\
697+
## Your widget here
698+
699+
The Textual API allows you to [build custom re-usable widgets](https://textual.textualize.io/guide/widgets/#custom-widgets) and share them across projects.
700+
Custom widgets can be themed, just like the builtin widget library.
701+
702+
Combine existing widgets to add new functionality, or use the powerful [Line API](https://textual.textualize.io/guide/widgets/#line-api) for unique creations.
703+
704+
"""
705+
706+
def compose(self) -> ComposeResult:
707+
yield Markdown(self.YOUR_MD)
585708

586709

587710
class WidgetsScreen(PageScreen):
@@ -607,15 +730,18 @@ class WidgetsScreen(PageScreen):
607730
BINDINGS = [Binding("escape", "blur", "Unfocus any focused widget", show=False)]
608731

609732
def compose(self) -> ComposeResult:
610-
with lazy.Reveal(containers.VerticalScroll(can_focus=False)):
733+
with lazy.Reveal(containers.VerticalScroll(can_focus=True)):
611734
yield Markdown(WIDGETS_MD, classes="column")
612735
yield Buttons()
613736
yield Checkboxes()
614737
yield Datatables()
615738
yield Inputs()
616739
yield ListViews()
617740
yield Logs()
741+
yield Markdowns()
742+
yield Selects()
618743
yield Sparklines()
619744
yield Switches()
620745
yield TextAreas()
746+
yield YourWidgets()
621747
yield Footer()

src/textual/lazy.py

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

55
from __future__ import annotations
66

7-
from functools import partial
8-
97
from textual.widget import Widget
108

119

@@ -66,82 +64,72 @@ async def mount() -> None:
6664

6765

6866
class Reveal(Widget):
67+
"""Similar to [Lazy][textual.lazy.Lazy], but mounts children sequentially.
68+
69+
This is useful when you have so many child widgets that there is a noticeable delay before
70+
you see anything. By mounting the children over several frames, the user will feel that
71+
something is happening.
72+
73+
Example:
74+
```python
75+
def compose(self) -> ComposeResult:
76+
with lazy.Reveal(containers.VerticalScroll(can_focus=False)):
77+
yield Markdown(WIDGETS_MD, classes="column")
78+
yield Buttons()
79+
yield Checkboxes()
80+
yield Datatables()
81+
yield Inputs()
82+
yield ListViews()
83+
yield Logs()
84+
yield Sparklines()
85+
yield Footer()
86+
```
87+
"""
88+
6989
DEFAULT_CSS = """
7090
Reveal {
7191
display: none;
7292
}
7393
"""
7494

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-
95+
def __init__(self, widget: Widget) -> None:
96+
"""
10097
Args:
101-
widget: A widget that should be mounted after a refresh.
102-
delay: A (short) delay between mounting widgets.
98+
widget: A widget to mount.
10399
"""
104100
self._replace_widget = widget
105-
self._delay = delay
101+
self._widgets: list[Widget] = []
106102
super().__init__()
107103

108104
@classmethod
109-
def _reveal(cls, parent: Widget, delay: float = 1 / 60) -> None:
105+
def _reveal(cls, parent: Widget, widgets: list[Widget]) -> None:
110106
"""Reveal children lazily.
111107
112108
Args:
113109
parent: The parent widget.
114-
delay: A delay between reveals.
110+
widgets: Child widgets.
115111
"""
116112

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()
113+
async def check_children() -> None:
114+
"""Check for pending children"""
115+
if not widgets:
116+
return
117+
widget = widgets.pop(0)
118+
await parent.mount(widget)
119+
if widgets:
120+
parent.call_next(check_children)
121+
122+
parent.call_next(check_children)
132123

133124
def compose_add_child(self, widget: Widget) -> None:
134-
widget.display = False
135-
self._replace_widget.compose_add_child(widget)
125+
self._widgets.append(widget)
136126

137127
async def mount_composed_widgets(self, widgets: list[Widget]) -> None:
138128
parent = self.parent
139129
if parent is None:
140130
return
141131
assert isinstance(parent, Widget)
142-
143-
if self._replace_widget.children:
144-
self._replace_widget.children[0].display = True
145132
await parent.mount(self._replace_widget, after=self)
146133
await self.remove()
147-
self._reveal(self._replace_widget, self._delay)
134+
self._reveal(self._replace_widget, self._widgets.copy())
135+
self._widgets.clear()

src/textual/widget.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from collections import Counter
1010
from contextlib import asynccontextmanager
1111
from fractions import Fraction
12+
from time import monotonic
1213
from types import TracebackType
1314
from typing import (
1415
TYPE_CHECKING,
@@ -491,6 +492,8 @@ def __init__(
491492
"""Used to cache :last-of-type pseudoclass state."""
492493
self._odd: tuple[int, bool] = (-1, False)
493494
"""Used to cache :odd pseudoclass state."""
495+
self._last_scroll_time = monotonic()
496+
"""Time of last scroll."""
494497

495498
@property
496499
def is_mounted(self) -> bool:
@@ -2211,6 +2214,7 @@ def is_scrollable(self) -> bool:
22112214
@property
22122215
def is_scrolling(self) -> bool:
22132216
"""Is this widget currently scrolling?"""
2217+
current_time = monotonic()
22142218
for node in self.ancestors:
22152219
if not isinstance(node, Widget):
22162220
break
@@ -2219,6 +2223,9 @@ def is_scrolling(self) -> bool:
22192223
or node.scroll_y != node.scroll_target_y
22202224
):
22212225
return True
2226+
if current_time - node._last_scroll_time < 0.1:
2227+
# Scroll ended very recently
2228+
return True
22222229
return False
22232230

22242231
@property
@@ -2360,6 +2367,11 @@ def _scroll_to(
23602367
animator.force_stop_animation(self, "scroll_x")
23612368
animator.force_stop_animation(self, "scroll_y")
23622369

2370+
def _animate_on_complete():
2371+
self._last_scroll_time = monotonic()
2372+
if on_complete is not None:
2373+
self.call_next(on_complete)
2374+
23632375
if animate:
23642376
# TODO: configure animation speed
23652377
if duration is None and speed is None:
@@ -2378,7 +2390,7 @@ def _scroll_to(
23782390
speed=speed,
23792391
duration=duration,
23802392
easing=easing,
2381-
on_complete=on_complete,
2393+
on_complete=_animate_on_complete,
23822394
level=level,
23832395
)
23842396
scrolled_x = True
@@ -2392,7 +2404,7 @@ def _scroll_to(
23922404
speed=speed,
23932405
duration=duration,
23942406
easing=easing,
2395-
on_complete=on_complete,
2407+
on_complete=_animate_on_complete,
23962408
level=level,
23972409
)
23982410
scrolled_y = True
@@ -2409,6 +2421,7 @@ def _scroll_to(
24092421
self.scroll_target_y = self.scroll_y = y
24102422
scrolled_y = scroll_y != self.scroll_y
24112423

2424+
self._last_scroll_time = monotonic()
24122425
if on_complete is not None:
24132426
self.call_after_refresh(on_complete)
24142427

@@ -2892,6 +2905,7 @@ def scroll_up(
28922905
force=force,
28932906
on_complete=on_complete,
28942907
level=level,
2908+
immediate=immediate,
28952909
)
28962910

28972911
def _scroll_up_for_pointer(

0 commit comments

Comments
 (0)