mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
@@ -25,18 +25,59 @@ You can install Textual via PyPI.
|
|||||||
If you plan on developing Textual apps, then you should install `textual[dev]`. The `[dev]` part installs a few extra dependencies for development.
|
If you plan on developing Textual apps, then you should install `textual[dev]`. The `[dev]` part installs a few extra dependencies for development.
|
||||||
|
|
||||||
```
|
```
|
||||||
pip install "textual[dev]==0.2.0b7"
|
pip install "textual[dev]==0.2.0b8"
|
||||||
```
|
```
|
||||||
|
|
||||||
If you only plan on _running_ Textual apps, then you can drop the `[dev]` part:
|
If you only plan on _running_ Textual apps, then you can drop the `[dev]` part:
|
||||||
|
|
||||||
```
|
```
|
||||||
pip install textual==0.2.0b7
|
pip install textual==0.2.0b8
|
||||||
```
|
```
|
||||||
|
|
||||||
!!! important
|
## Demo
|
||||||
|
|
||||||
|
Once you have Textual installed, run the following to get an impression of what it can do:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m textual
|
||||||
|
```
|
||||||
|
|
||||||
|
If Textual is installed you should see the following:
|
||||||
|
|
||||||
|
```{.textual path="src/textual/demo.py" columns="127" lines="53" press="enter,_,_,_,_,_,_,tab,w,i,l,l"}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
|
||||||
|
The Textual repository comes with a number of example apps. To try out the examples, first clone the Textual repository:
|
||||||
|
|
||||||
|
=== "HTTPS"
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone -b css https://github.com/Textualize/textual.git
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "SSH"
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone -b css git@github.com:Textualize/textual.git
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "GitHub CLI"
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gh repo clone -b css Textualize/textual
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
With the repository cloned, navigate to the `/examples/` directory where you fill find a number of Python files you can run from the command line:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd textual/examples/
|
||||||
|
python code_browser.py ../
|
||||||
|
```
|
||||||
|
|
||||||
There may be a more recent beta version since the time of writing. Check the [release history](https://pypi.org/project/textual/#history) for a more recent version.
|
|
||||||
|
|
||||||
## Textual CLI
|
## Textual CLI
|
||||||
|
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ If you want to try the finished Stopwatch app and follow along with the code, fi
|
|||||||
=== "GitHub CLI"
|
=== "GitHub CLI"
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
gh repo clone Textualize/textual
|
gh repo clone -b css Textualize/textual
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -317,9 +317,8 @@ class FiveByFive(App[None]):
|
|||||||
#: App-level bindings.
|
#: App-level bindings.
|
||||||
BINDINGS = [("ctrl+d", "toggle_dark", "Toggle Dark Mode")]
|
BINDINGS = [("ctrl+d", "toggle_dark", "Toggle Dark Mode")]
|
||||||
|
|
||||||
def __init__(self) -> None:
|
# Set the title
|
||||||
"""Constructor."""
|
TITLE = "5x5 -- A little annoying puzzle"
|
||||||
super().__init__(title="5x5 -- A little annoying puzzle")
|
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
def on_mount(self) -> None:
|
||||||
"""Set up the application on startup."""
|
"""Set up the application on startup."""
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "textual"
|
name = "textual"
|
||||||
version = "0.2.0b7"
|
version = "0.2.0b8"
|
||||||
homepage = "https://github.com/Textualize/textual"
|
homepage = "https://github.com/Textualize/textual"
|
||||||
description = "Modern Text User Interface framework"
|
description = "Modern Text User Interface framework"
|
||||||
authors = ["Will McGugan <will@textualize.io>"]
|
authors = ["Will McGugan <will@textualize.io>"]
|
||||||
|
|||||||
6
src/textual/__main__.py
Normal file
6
src/textual/__main__.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from .demo import DemoApp
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app = DemoApp()
|
||||||
|
app.run()
|
||||||
@@ -86,6 +86,7 @@ class SimpleAnimation(Animation):
|
|||||||
assert isinstance(
|
assert isinstance(
|
||||||
self.end_value, (int, float)
|
self.end_value, (int, float)
|
||||||
), f"`end_value` must be float, not {self.end_value!r}"
|
), f"`end_value` must be float, not {self.end_value!r}"
|
||||||
|
|
||||||
if self.end_value > self.start_value:
|
if self.end_value > self.start_value:
|
||||||
eased_factor = self.easing(factor)
|
eased_factor = self.easing(factor)
|
||||||
value = (
|
value = (
|
||||||
|
|||||||
@@ -140,18 +140,18 @@ class App(Generic[ReturnType], DOMNode):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
SCREENS: dict[str, Screen] = {}
|
SCREENS: dict[str, Screen] = {}
|
||||||
|
|
||||||
_BASE_PATH: str | None = None
|
_BASE_PATH: str | None = None
|
||||||
CSS_PATH: CSSPathType = None
|
CSS_PATH: CSSPathType = None
|
||||||
|
TITLE: str | None = None
|
||||||
|
SUB_TITLE: str | None = None
|
||||||
|
|
||||||
title: Reactive[str] = Reactive("Textual")
|
title: Reactive[str] = Reactive("")
|
||||||
sub_title: Reactive[str] = Reactive("")
|
sub_title: Reactive[str] = Reactive("")
|
||||||
dark: Reactive[bool] = Reactive(True)
|
dark: Reactive[bool] = Reactive(True)
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
driver_class: Type[Driver] | None = None,
|
driver_class: Type[Driver] | None = None,
|
||||||
title: str | None = None,
|
|
||||||
css_path: CSSPathType = None,
|
css_path: CSSPathType = None,
|
||||||
watch_css: bool = False,
|
watch_css: bool = False,
|
||||||
):
|
):
|
||||||
@@ -190,10 +190,10 @@ class App(Generic[ReturnType], DOMNode):
|
|||||||
self._animator = Animator(self)
|
self._animator = Animator(self)
|
||||||
self._animate = self._animator.bind(self)
|
self._animate = self._animator.bind(self)
|
||||||
self.mouse_position = Offset(0, 0)
|
self.mouse_position = Offset(0, 0)
|
||||||
if title is None:
|
self.title = (
|
||||||
self.title = f"{self.__class__.__name__}"
|
self.TITLE if self.TITLE is not None else f"{self.__class__.__name__}"
|
||||||
else:
|
)
|
||||||
self.title = title
|
self.sub_title = self.SUB_TITLE if self.SUB_TITLE is not None else ""
|
||||||
|
|
||||||
self._logger = Logger(self._log)
|
self._logger = Logger(self._log)
|
||||||
|
|
||||||
@@ -480,7 +480,7 @@ class App(Generic[ReturnType], DOMNode):
|
|||||||
"""Action to toggle dark mode."""
|
"""Action to toggle dark mode."""
|
||||||
self.dark = not self.dark
|
self.dark = not self.dark
|
||||||
|
|
||||||
def action_screenshot(self, filename: str | None, path: str = "~/") -> None:
|
def action_screenshot(self, filename: str | None = None, path: str = "./") -> None:
|
||||||
"""Save an SVG "screenshot". This action will save an SVG file containing the current contents of the screen.
|
"""Save an SVG "screenshot". This action will save an SVG file containing the current contents of the screen.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -1345,6 +1345,7 @@ class App(Generic[ReturnType], DOMNode):
|
|||||||
Returns:
|
Returns:
|
||||||
bool: True if the event has handled.
|
bool: True if the event has handled.
|
||||||
"""
|
"""
|
||||||
|
print("ACTION", action, default_namespace)
|
||||||
if isinstance(action, str):
|
if isinstance(action, str):
|
||||||
target, params = actions.parse(action)
|
target, params = actions.parse(action)
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -80,6 +80,8 @@ def get_box_model(
|
|||||||
max_width = styles.max_width.resolve_dimension(
|
max_width = styles.max_width.resolve_dimension(
|
||||||
content_container, viewport, fraction_unit
|
content_container, viewport, fraction_unit
|
||||||
)
|
)
|
||||||
|
if is_border_box:
|
||||||
|
max_width -= gutter.width
|
||||||
content_width = min(content_width, max_width)
|
content_width = min(content_width, max_width)
|
||||||
|
|
||||||
content_width = max(Fraction(0), content_width)
|
content_width = max(Fraction(0), content_width)
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ class BorderApp(App):
|
|||||||
event.button.id,
|
event.button.id,
|
||||||
self.stylesheet._variables["secondary"],
|
self.stylesheet._variables["secondary"],
|
||||||
)
|
)
|
||||||
|
self.bell()
|
||||||
|
|
||||||
|
|
||||||
app = BorderApp()
|
app = BorderApp()
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
* {
|
|
||||||
transition: color 300ms linear, background 300ms linear;
|
|
||||||
}
|
|
||||||
|
|
||||||
ColorButtons {
|
ColorButtons {
|
||||||
dock: left;
|
dock: left;
|
||||||
|
|||||||
@@ -78,10 +78,11 @@ class ColorsApp(App):
|
|||||||
content.mount(ColorsView())
|
content.mount(ColorsView())
|
||||||
|
|
||||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||||
|
self.bell()
|
||||||
self.query(ColorGroup).remove_class("-active")
|
self.query(ColorGroup).remove_class("-active")
|
||||||
group = self.query_one(f"#group-{event.button.id}", ColorGroup)
|
group = self.query_one(f"#group-{event.button.id}", ColorGroup)
|
||||||
group.add_class("-active")
|
group.add_class("-active")
|
||||||
group.scroll_visible(speed=150)
|
group.scroll_visible(top=True, speed=150)
|
||||||
|
|
||||||
|
|
||||||
app = ColorsApp()
|
app = ColorsApp()
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ class EasingApp(App):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||||
|
self.bell()
|
||||||
self.animated_bar.animation_running = True
|
self.animated_bar.animation_running = True
|
||||||
|
|
||||||
def _animation_complete():
|
def _animation_complete():
|
||||||
|
|||||||
@@ -856,6 +856,7 @@ class ColorProperty:
|
|||||||
elif isinstance(color, Color):
|
elif isinstance(color, Color):
|
||||||
if obj.set_rule(self.name, color):
|
if obj.set_rule(self.name, color):
|
||||||
obj.refresh(children=self._is_background)
|
obj.refresh(children=self._is_background)
|
||||||
|
|
||||||
elif isinstance(color, str):
|
elif isinstance(color, str):
|
||||||
alpha = 1.0
|
alpha = 1.0
|
||||||
parsed_color = Color(255, 255, 255)
|
parsed_color = Color(255, 255, 255)
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ SELECTOR_MAP: dict[str, tuple[SelectorType, Specificity3]] = {
|
|||||||
|
|
||||||
@lru_cache(maxsize=1024)
|
@lru_cache(maxsize=1024)
|
||||||
def parse_selectors(css_selectors: str) -> tuple[SelectorSet, ...]:
|
def parse_selectors(css_selectors: str) -> tuple[SelectorSet, ...]:
|
||||||
|
|
||||||
|
if not css_selectors.strip():
|
||||||
|
return ()
|
||||||
|
|
||||||
tokens = iter(tokenize(css_selectors, ""))
|
tokens = iter(tokenize(css_selectors, ""))
|
||||||
|
|
||||||
get_selector = SELECTOR_MAP.get
|
get_selector = SELECTOR_MAP.get
|
||||||
|
|||||||
@@ -58,10 +58,13 @@ class ScalarAnimation(Animation):
|
|||||||
setattr(self.styles, self.attribute, self.final_value)
|
setattr(self.styles, self.attribute, self.final_value)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
offset = self.start + (self.destination - self.start) * eased_factor
|
if hasattr(self.start, "blend"):
|
||||||
current = self.styles._rules[self.attribute]
|
value = self.start.blend(self.destination, eased_factor)
|
||||||
if current != offset:
|
else:
|
||||||
setattr(self.styles, f"{self.attribute}", offset)
|
value = self.start + (self.destination - self.start) * eased_factor
|
||||||
|
current = self.styles._rules.get(self.attribute)
|
||||||
|
if current != value:
|
||||||
|
setattr(self.styles, f"{self.attribute}", value)
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|||||||
@@ -610,7 +610,6 @@ class Styles(StylesBase):
|
|||||||
list[tuple[str, Specificity6, Any]]]: A list containing a tuple of <RULE NAME>, <SPECIFICITY> <RULE VALUE>.
|
list[tuple[str, Specificity6, Any]]]: A list containing a tuple of <RULE NAME>, <SPECIFICITY> <RULE VALUE>.
|
||||||
"""
|
"""
|
||||||
is_important = self.important.__contains__
|
is_important = self.important.__contains__
|
||||||
|
|
||||||
rules = [
|
rules = [
|
||||||
(
|
(
|
||||||
rule_name,
|
rule_name,
|
||||||
@@ -644,9 +643,6 @@ class Styles(StylesBase):
|
|||||||
easing: EasingFunction,
|
easing: EasingFunction,
|
||||||
on_complete: CallbackType | None = None,
|
on_complete: CallbackType | None = None,
|
||||||
) -> ScalarAnimation | None:
|
) -> ScalarAnimation | None:
|
||||||
# from ..widget import Widget
|
|
||||||
# node = self.node
|
|
||||||
# assert isinstance(self.node, Widget)
|
|
||||||
if isinstance(value, ScalarOffset):
|
if isinstance(value, ScalarOffset):
|
||||||
return ScalarAnimation(
|
return ScalarAnimation(
|
||||||
self.node,
|
self.node,
|
||||||
|
|||||||
@@ -370,11 +370,14 @@ class Stylesheet:
|
|||||||
|
|
||||||
# Collect the rules defined in the stylesheet
|
# Collect the rules defined in the stylesheet
|
||||||
node._has_hover_style = False
|
node._has_hover_style = False
|
||||||
|
node._has_focus_within = False
|
||||||
for rule in rules:
|
for rule in rules:
|
||||||
is_default_rules = rule.is_default_rules
|
is_default_rules = rule.is_default_rules
|
||||||
tie_breaker = rule.tie_breaker
|
tie_breaker = rule.tie_breaker
|
||||||
if ":hover" in rule.selector_names:
|
if ":hover" in rule.selector_names:
|
||||||
node._has_hover_style = True
|
node._has_hover_style = True
|
||||||
|
if ":focus-within" in rule.selector_names:
|
||||||
|
node._has_focus_within = True
|
||||||
for base_specificity in _check_rule(rule, css_path_nodes):
|
for base_specificity in _check_rule(rule, css_path_nodes):
|
||||||
for key, rule_specificity, value in rule.styles.extract_rules(
|
for key, rule_specificity, value in rule.styles.extract_rules(
|
||||||
base_specificity, is_default_rules, tie_breaker
|
base_specificity, is_default_rules, tie_breaker
|
||||||
|
|||||||
254
src/textual/demo.css
Normal file
254
src/textual/demo.css
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
* {
|
||||||
|
transition: background 250ms linear, color 250ms linear;
|
||||||
|
}
|
||||||
|
|
||||||
|
Screen {
|
||||||
|
layers: base overlay notes notifications;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
Notification {
|
||||||
|
dock: bottom;
|
||||||
|
layer: notification;
|
||||||
|
width: auto;
|
||||||
|
margin: 2 4;
|
||||||
|
padding: 1 2;
|
||||||
|
background: $background;
|
||||||
|
color: $text;
|
||||||
|
height: auto;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
Sidebar {
|
||||||
|
width: 40;
|
||||||
|
background: $panel;
|
||||||
|
transition: offset 500ms in_out_cubic;
|
||||||
|
layer: overlay;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
Sidebar:focus-within {
|
||||||
|
offset: 0 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
Sidebar.-hidden {
|
||||||
|
offset-x: -100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
Sidebar Title {
|
||||||
|
background: $boost;
|
||||||
|
color: $secondary;
|
||||||
|
padding: 2 4;
|
||||||
|
border-right: vkey $background;
|
||||||
|
dock: top;
|
||||||
|
text-align: center;
|
||||||
|
text-style: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
OptionGroup {
|
||||||
|
background: $boost;
|
||||||
|
color: $text;
|
||||||
|
height: 1fr;
|
||||||
|
border-right: vkey $background;
|
||||||
|
}
|
||||||
|
|
||||||
|
Option {
|
||||||
|
margin: 1 0 0 1;
|
||||||
|
height: 3;
|
||||||
|
padding: 1 2;
|
||||||
|
background: $boost;
|
||||||
|
border: tall $panel;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
Option:hover {
|
||||||
|
background: $primary 20%;
|
||||||
|
color: $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
Body {
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: scroll;
|
||||||
|
width: 100%;
|
||||||
|
background: $surface;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
AboveFold {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
align: center middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
Welcome {
|
||||||
|
background: $boost;
|
||||||
|
height: auto;
|
||||||
|
max-width: 100;
|
||||||
|
min-width: 40;
|
||||||
|
border: wide $primary;
|
||||||
|
padding: 1 2;
|
||||||
|
margin: 1 2;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
Welcome Button {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
Column {
|
||||||
|
height: auto;
|
||||||
|
min-height: 100vh;
|
||||||
|
align: center top;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
DarkSwitch {
|
||||||
|
background: $panel;
|
||||||
|
padding: 1;
|
||||||
|
dock: bottom;
|
||||||
|
height: auto;
|
||||||
|
border-right: vkey $background;
|
||||||
|
}
|
||||||
|
|
||||||
|
DarkSwitch .label {
|
||||||
|
|
||||||
|
padding: 1 2;
|
||||||
|
color: $text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
DarkSwitch Checkbox {
|
||||||
|
background: $boost;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
Screen > Container {
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
TextLog {
|
||||||
|
background: $surface;
|
||||||
|
color: $text;
|
||||||
|
height: 50vh;
|
||||||
|
dock: bottom;
|
||||||
|
layer: notes;
|
||||||
|
border-top: hkey $primary;
|
||||||
|
offset-y: 0;
|
||||||
|
transition: offset 400ms in_out_cubic;
|
||||||
|
padding: 0 1 1 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TextLog:focus {
|
||||||
|
offset: 0 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
TextLog.-hidden {
|
||||||
|
offset-y: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Section {
|
||||||
|
height: auto;
|
||||||
|
min-width: 40;
|
||||||
|
margin: 1 2 4 2;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
SectionTitle {
|
||||||
|
padding: 1 2;
|
||||||
|
background: $boost;
|
||||||
|
text-align: center;
|
||||||
|
text-style: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
SubTitle {
|
||||||
|
padding-top: 1;
|
||||||
|
border-bottom: heavy $panel;
|
||||||
|
color: $text;
|
||||||
|
text-style: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
TextContent {
|
||||||
|
margin: 1 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
QuickAccess {
|
||||||
|
width: 30;
|
||||||
|
dock: left;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
LocationLink {
|
||||||
|
margin: 1 0 0 1;
|
||||||
|
height: 1;
|
||||||
|
padding: 1 2;
|
||||||
|
background: $boost;
|
||||||
|
color: $text;
|
||||||
|
|
||||||
|
content-align: center middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
LocationLink:hover {
|
||||||
|
background: $accent;
|
||||||
|
color: $text;
|
||||||
|
text-style: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.pad {
|
||||||
|
margin: 1 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
DataTable {
|
||||||
|
height: 16;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
LoginForm {
|
||||||
|
height: auto;
|
||||||
|
margin: 1 0;
|
||||||
|
padding: 1 2;
|
||||||
|
layout: grid;
|
||||||
|
grid-size: 2;
|
||||||
|
grid-rows: 4;
|
||||||
|
grid-columns: 12 1fr;
|
||||||
|
background: $boost;
|
||||||
|
border: wide $background;
|
||||||
|
}
|
||||||
|
|
||||||
|
LoginForm Button{
|
||||||
|
margin: 0 1;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
LoginForm .label {
|
||||||
|
padding: 1 2;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
Message {
|
||||||
|
margin: 0 1;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TreeControl {
|
||||||
|
margin: 1 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
Window {
|
||||||
|
background: $boost;
|
||||||
|
overflow: auto;
|
||||||
|
height: auto;
|
||||||
|
max-height: 16;
|
||||||
|
}
|
||||||
|
|
||||||
|
Window > Static {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
419
src/textual/demo.py
Normal file
419
src/textual/demo.py
Normal file
@@ -0,0 +1,419 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from rich import box
|
||||||
|
from rich.console import RenderableType
|
||||||
|
from rich.json import JSON
|
||||||
|
from rich.markdown import Markdown
|
||||||
|
from rich.pretty import Pretty
|
||||||
|
from rich.syntax import Syntax
|
||||||
|
from rich.table import Table
|
||||||
|
from rich.text import Text
|
||||||
|
|
||||||
|
from textual.app import App, ComposeResult
|
||||||
|
from textual.binding import Binding
|
||||||
|
from textual.containers import Container, Horizontal
|
||||||
|
from textual.reactive import reactive, watch
|
||||||
|
from textual.widgets import (
|
||||||
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
DataTable,
|
||||||
|
Footer,
|
||||||
|
Header,
|
||||||
|
Input,
|
||||||
|
Static,
|
||||||
|
TextLog,
|
||||||
|
)
|
||||||
|
|
||||||
|
from_markup = Text.from_markup
|
||||||
|
|
||||||
|
example_table = Table(
|
||||||
|
show_edge=False,
|
||||||
|
show_header=True,
|
||||||
|
expand=True,
|
||||||
|
row_styles=["none", "dim"],
|
||||||
|
box=box.SIMPLE,
|
||||||
|
)
|
||||||
|
example_table.add_column(from_markup("[green]Date"), style="green", no_wrap=True)
|
||||||
|
example_table.add_column(from_markup("[blue]Title"), style="blue")
|
||||||
|
|
||||||
|
example_table.add_column(
|
||||||
|
from_markup("[magenta]Box Office"),
|
||||||
|
style="magenta",
|
||||||
|
justify="right",
|
||||||
|
no_wrap=True,
|
||||||
|
)
|
||||||
|
example_table.add_row(
|
||||||
|
"Dec 20, 2019",
|
||||||
|
"Star Wars: The Rise of Skywalker",
|
||||||
|
"$375,126,118",
|
||||||
|
)
|
||||||
|
example_table.add_row(
|
||||||
|
"May 25, 2018",
|
||||||
|
from_markup("[b]Solo[/]: A Star Wars Story"),
|
||||||
|
"$393,151,347",
|
||||||
|
)
|
||||||
|
example_table.add_row(
|
||||||
|
"Dec 15, 2017",
|
||||||
|
"Star Wars Ep. VIII: The Last Jedi",
|
||||||
|
from_markup("[bold]$1,332,539,889[/bold]"),
|
||||||
|
)
|
||||||
|
example_table.add_row(
|
||||||
|
"May 19, 1999",
|
||||||
|
from_markup("Star Wars Ep. [b]I[/b]: [i]The phantom Menace"),
|
||||||
|
"$1,027,044,677",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
WELCOME_MD = """
|
||||||
|
|
||||||
|
## Textual Demo
|
||||||
|
|
||||||
|
**Welcome**! Textual is a framework for creating sophisticated applications with the terminal.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
RICH_MD = """
|
||||||
|
|
||||||
|
Textual is built on **Rich**, the popular Python library for advanced terminal output.
|
||||||
|
|
||||||
|
Add content to your Textual App with Rich *renderables* (this text is written in Markdown and formatted with Rich's Markdown class).
|
||||||
|
|
||||||
|
Here are some examples:
|
||||||
|
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
CSS_MD = """
|
||||||
|
|
||||||
|
Textual uses Cascading Stylesheets (CSS) to create Rich interactive User Interfaces.
|
||||||
|
|
||||||
|
- **Easy to learn** - much simpler than browser CSS
|
||||||
|
- **Live editing** - see your changes without restarting the app!
|
||||||
|
|
||||||
|
Here's an example of some CSS used in this app:
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
EXAMPLE_CSS = """\
|
||||||
|
Screen {
|
||||||
|
layers: base overlay notes;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
Sidebar {
|
||||||
|
width: 40;
|
||||||
|
background: $panel;
|
||||||
|
transition: offset 500ms in_out_cubic;
|
||||||
|
layer: overlay;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
Sidebar.-hidden {
|
||||||
|
offset-x: -100%;
|
||||||
|
}"""
|
||||||
|
|
||||||
|
DATA = {
|
||||||
|
"foo": [
|
||||||
|
3.1427,
|
||||||
|
(
|
||||||
|
"Paul Atreides",
|
||||||
|
"Vladimir Harkonnen",
|
||||||
|
"Thufir Hawat",
|
||||||
|
"Gurney Halleck",
|
||||||
|
"Duncan Idaho",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
WIDGETS_MD = """
|
||||||
|
|
||||||
|
Textual widgets are powerful interactive components.
|
||||||
|
|
||||||
|
Build your own or use the builtin widgets.
|
||||||
|
|
||||||
|
- **Input** Text / Password input.
|
||||||
|
- **Button** Clickable button with a number of styles.
|
||||||
|
- **Checkbox** A checkbox to toggle between states.
|
||||||
|
- **DataTable** A spreadsheet-like widget for navigating data. Cells may contain text or Rich renderables.
|
||||||
|
- **TreeControl** An generic tree with expandable nodes.
|
||||||
|
- **DirectoryTree** A tree of file and folders.
|
||||||
|
- *... many more planned ...*
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
MESSAGE = """
|
||||||
|
We hope you enjoy using Textual.
|
||||||
|
|
||||||
|
Here are some links. You can click these!
|
||||||
|
|
||||||
|
[@click="app.open_link('https://textual.textualize.io')"]Textual Docs[/]
|
||||||
|
|
||||||
|
[@click="app.open_link('https://github.com/Textualize/textual')"]Textual GitHub Repository[/]
|
||||||
|
|
||||||
|
[@click="app.open_link('https://github.com/Textualize/rich')"]Rich GitHub Repository[/]
|
||||||
|
|
||||||
|
|
||||||
|
Built with ♥ by [@click="app.open_link(https://www.textualize.io)"]Textualize.io[/]
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
JSON_EXAMPLE = """{
|
||||||
|
"glossary": {
|
||||||
|
"title": "example glossary",
|
||||||
|
"GlossDiv": {
|
||||||
|
"title": "S",
|
||||||
|
"GlossList": {
|
||||||
|
"GlossEntry": {
|
||||||
|
"ID": "SGML",
|
||||||
|
"SortAs": "SGML",
|
||||||
|
"GlossTerm": "Standard Generalized Markup Language",
|
||||||
|
"Acronym": "SGML",
|
||||||
|
"Abbrev": "ISO 8879:1986",
|
||||||
|
"GlossDef": {
|
||||||
|
"para": "A meta-markup language, used to create markup languages such as DocBook.",
|
||||||
|
"GlossSeeAlso": ["GML", "XML"]
|
||||||
|
},
|
||||||
|
"GlossSee": "markup"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class Body(Container):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Title(Static):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class DarkSwitch(Horizontal):
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield Checkbox(value=self.app.dark)
|
||||||
|
yield Static("Dark mode", classes="label")
|
||||||
|
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
watch(self.app, "dark", self.on_dark_change)
|
||||||
|
|
||||||
|
def on_dark_change(self, dark: bool) -> None:
|
||||||
|
self.query_one(Checkbox).value = self.app.dark
|
||||||
|
|
||||||
|
def on_checkbox_changed(self, event: Checkbox.Changed) -> None:
|
||||||
|
self.app.dark = event.value
|
||||||
|
|
||||||
|
|
||||||
|
class Welcome(Container):
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield Static(Markdown(WELCOME_MD))
|
||||||
|
yield Button("Start", variant="success")
|
||||||
|
|
||||||
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||||
|
self.app.add_note("[b magenta]Start!")
|
||||||
|
self.app.query_one(".location-first").scroll_visible(speed=50, top=True)
|
||||||
|
|
||||||
|
|
||||||
|
class OptionGroup(Container):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class SectionTitle(Static):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Message(Static):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Sidebar(Container):
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield Title("Textual Demo")
|
||||||
|
yield OptionGroup(Message(MESSAGE))
|
||||||
|
yield DarkSwitch()
|
||||||
|
|
||||||
|
|
||||||
|
class AboveFold(Container):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Section(Container):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Column(Container):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class TextContent(Static):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class QuickAccess(Container):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class LocationLink(Static):
|
||||||
|
def __init__(self, label: str, reveal: str) -> None:
|
||||||
|
super().__init__(label)
|
||||||
|
self.reveal = reveal
|
||||||
|
|
||||||
|
def on_click(self) -> None:
|
||||||
|
self.app.query_one(self.reveal).scroll_visible(top=True)
|
||||||
|
self.app.add_note(f"Scrolling to [b]{self.reveal}[/b]")
|
||||||
|
|
||||||
|
|
||||||
|
class LoginForm(Container):
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield Static("Username", classes="label")
|
||||||
|
yield Input(placeholder="Username")
|
||||||
|
yield Static("Password", classes="label")
|
||||||
|
yield Input(placeholder="Password", password=True)
|
||||||
|
yield Static()
|
||||||
|
yield Button("Login", variant="primary")
|
||||||
|
|
||||||
|
|
||||||
|
class Window(Container):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class SubTitle(Static):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Notification(Static):
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
self.set_timer(3, self.remove)
|
||||||
|
|
||||||
|
def on_click(self) -> None:
|
||||||
|
self.remove()
|
||||||
|
|
||||||
|
|
||||||
|
class DemoApp(App):
|
||||||
|
CSS_PATH = "demo.css"
|
||||||
|
TITLE = "Textual Demo"
|
||||||
|
BINDINGS = [
|
||||||
|
("ctrl+b", "toggle_sidebar", "Sidebar"),
|
||||||
|
("ctrl+t", "app.toggle_dark", "Toggle Dark mode"),
|
||||||
|
("ctrl+s", "app.screenshot()", "Screenshot"),
|
||||||
|
("f1", "app.toggle_class('TextLog', '-hidden')", "Notes"),
|
||||||
|
Binding("ctrl+c,ctrl+q", "app.quit", "Quit", show=True),
|
||||||
|
]
|
||||||
|
|
||||||
|
show_sidebar = reactive(False)
|
||||||
|
|
||||||
|
def add_note(self, renderable: RenderableType) -> None:
|
||||||
|
self.query_one(TextLog).write(renderable)
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
example_css = "\n".join(Path(self.css_path).read_text().splitlines()[:50])
|
||||||
|
yield Container(
|
||||||
|
Sidebar(classes="-hidden"),
|
||||||
|
Header(show_clock=True),
|
||||||
|
TextLog(classes="-hidden", wrap=False, highlight=True, markup=True),
|
||||||
|
Body(
|
||||||
|
QuickAccess(
|
||||||
|
LocationLink("TOP", ".location-top"),
|
||||||
|
LocationLink("Widgets", ".location-widgets"),
|
||||||
|
LocationLink("Rich content", ".location-rich"),
|
||||||
|
LocationLink("CSS", ".location-css"),
|
||||||
|
),
|
||||||
|
AboveFold(Welcome(), classes="location-top"),
|
||||||
|
Column(
|
||||||
|
Section(
|
||||||
|
SectionTitle("Widgets"),
|
||||||
|
TextContent(Markdown(WIDGETS_MD)),
|
||||||
|
LoginForm(),
|
||||||
|
DataTable(),
|
||||||
|
),
|
||||||
|
classes="location-widgets location-first",
|
||||||
|
),
|
||||||
|
Column(
|
||||||
|
Section(
|
||||||
|
SectionTitle("Rich"),
|
||||||
|
TextContent(Markdown(RICH_MD)),
|
||||||
|
SubTitle("Pretty Printed data (try resizing the terminal)"),
|
||||||
|
Static(Pretty(DATA, indent_guides=True), classes="pretty pad"),
|
||||||
|
SubTitle("JSON"),
|
||||||
|
Window(Static(JSON(JSON_EXAMPLE), expand=True), classes="pad"),
|
||||||
|
SubTitle("Tables"),
|
||||||
|
Static(example_table, classes="table pad"),
|
||||||
|
),
|
||||||
|
classes="location-rich",
|
||||||
|
),
|
||||||
|
Column(
|
||||||
|
Section(
|
||||||
|
SectionTitle("CSS"),
|
||||||
|
TextContent(Markdown(CSS_MD)),
|
||||||
|
Window(
|
||||||
|
Static(
|
||||||
|
Syntax(
|
||||||
|
example_css,
|
||||||
|
"css",
|
||||||
|
theme="material",
|
||||||
|
line_numbers=True,
|
||||||
|
),
|
||||||
|
expand=True,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
),
|
||||||
|
classes="location-css",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
yield Footer()
|
||||||
|
|
||||||
|
def action_open_link(self, link: str) -> None:
|
||||||
|
self.app.bell()
|
||||||
|
import webbrowser
|
||||||
|
|
||||||
|
webbrowser.open(link)
|
||||||
|
|
||||||
|
def action_toggle_sidebar(self) -> None:
|
||||||
|
|
||||||
|
sidebar = self.query_one(Sidebar)
|
||||||
|
if sidebar.has_class("-hidden"):
|
||||||
|
sidebar.remove_class("-hidden")
|
||||||
|
else:
|
||||||
|
if sidebar.query("*:focus"):
|
||||||
|
self.screen.set_focus(None)
|
||||||
|
sidebar.add_class("-hidden")
|
||||||
|
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
self.add_note("Textual Demo app is running")
|
||||||
|
table = self.query_one(DataTable)
|
||||||
|
table.add_column("Foo", width=20)
|
||||||
|
table.add_column("Bar", width=20)
|
||||||
|
table.add_column("Baz", width=20)
|
||||||
|
table.add_column("Foo", width=20)
|
||||||
|
table.add_column("Bar", width=20)
|
||||||
|
table.add_column("Baz", width=20)
|
||||||
|
table.zebra_stripes = True
|
||||||
|
for n in range(20):
|
||||||
|
table.add_row(*[f"Cell ([b]{n}[/b], {col})" for col in range(6)])
|
||||||
|
self.query_one("Welcome Button", Button).focus()
|
||||||
|
|
||||||
|
def action_screenshot(self, filename: str | None = None, path: str = "./") -> None:
|
||||||
|
"""Save an SVG "screenshot". This action will save an SVG file containing the current contents of the screen.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filename (str | None, optional): Filename of screenshot, or None to auto-generate. Defaults to None.
|
||||||
|
path (str, optional): Path to directory. Defaults to "./".
|
||||||
|
"""
|
||||||
|
self.bell()
|
||||||
|
path = self.save_screenshot(filename, path)
|
||||||
|
message = Text.assemble("Screenshot saved to ", (f"'{path}'", "bold green"))
|
||||||
|
self.add_note(message)
|
||||||
|
self.screen.mount(Notification(message))
|
||||||
|
|
||||||
|
|
||||||
|
app = DemoApp()
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app.run()
|
||||||
@@ -128,7 +128,7 @@ class ColorSystem:
|
|||||||
boost = self.boost or background.get_contrast_text(1.0).with_alpha(0.04)
|
boost = self.boost or background.get_contrast_text(1.0).with_alpha(0.04)
|
||||||
|
|
||||||
if self.panel is None:
|
if self.panel is None:
|
||||||
panel = surface.blend(primary, 0.1)
|
panel = surface.blend(primary, 0.1, alpha=1)
|
||||||
if dark:
|
if dark:
|
||||||
panel += boost
|
panel += boost
|
||||||
else:
|
else:
|
||||||
@@ -154,7 +154,7 @@ class ColorSystem:
|
|||||||
yield (f"{label}{'-' + str(abs(n)) if n else ''}"), n * luminosity_step
|
yield (f"{label}{'-' + str(abs(n)) if n else ''}"), n * luminosity_step
|
||||||
|
|
||||||
# Color names and color
|
# Color names and color
|
||||||
COLORS = [
|
COLORS: list[tuple[str, Color]] = [
|
||||||
("primary", primary),
|
("primary", primary),
|
||||||
("secondary", secondary),
|
("secondary", secondary),
|
||||||
("primary-background", primary),
|
("primary-background", primary),
|
||||||
@@ -178,9 +178,9 @@ class ColorSystem:
|
|||||||
spread = luminosity_spread
|
spread = luminosity_spread
|
||||||
for shade_name, luminosity_delta in luminosity_range(spread):
|
for shade_name, luminosity_delta in luminosity_range(spread):
|
||||||
if is_dark_shade:
|
if is_dark_shade:
|
||||||
dark_background = background.blend(color, 0.15)
|
dark_background = background.blend(color, 0.15, alpha=1.0)
|
||||||
shade_color = dark_background.blend(
|
shade_color = dark_background.blend(
|
||||||
WHITE, spread + luminosity_delta
|
WHITE, spread + luminosity_delta, alpha=1.0
|
||||||
).clamped
|
).clamped
|
||||||
colors[f"{name}{shade_name}"] = shade_color.hex
|
colors[f"{name}{shade_name}"] = shade_color.hex
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -140,6 +140,7 @@ class DOMNode(MessagePump):
|
|||||||
self._css_types = {cls.__name__ for cls in self._css_bases(self.__class__)}
|
self._css_types = {cls.__name__ for cls in self._css_bases(self.__class__)}
|
||||||
self._bindings = Bindings(self.BINDINGS)
|
self._bindings = Bindings(self.BINDINGS)
|
||||||
self._has_hover_style: bool = False
|
self._has_hover_style: bool = False
|
||||||
|
self._has_focus_within: bool = False
|
||||||
|
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
|
||||||
@@ -277,7 +278,7 @@ class DOMNode(MessagePump):
|
|||||||
while node and not isinstance(node, Screen):
|
while node and not isinstance(node, Screen):
|
||||||
node = node._parent
|
node = node._parent
|
||||||
if not isinstance(node, Screen):
|
if not isinstance(node, Screen):
|
||||||
raise NoScreen(f"{self} has no screen")
|
raise NoScreen("node has no screen")
|
||||||
return node
|
return node
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
@@ -307,7 +307,9 @@ class Region(NamedTuple):
|
|||||||
return cls(x, y, width, height)
|
return cls(x, y, width, height)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_scroll_to_visible(cls, window_region: Region, region: Region) -> Offset:
|
def get_scroll_to_visible(
|
||||||
|
cls, window_region: Region, region: Region, *, top: bool = False
|
||||||
|
) -> Offset:
|
||||||
"""Calculate the smallest offset required to translate a window so that it contains
|
"""Calculate the smallest offset required to translate a window so that it contains
|
||||||
another region.
|
another region.
|
||||||
|
|
||||||
@@ -316,6 +318,7 @@ class Region(NamedTuple):
|
|||||||
Args:
|
Args:
|
||||||
window_region (Region): The window region.
|
window_region (Region): The window region.
|
||||||
region (Region): The region to move inside the window.
|
region (Region): The region to move inside the window.
|
||||||
|
top (bool, optional): Get offset to top of window. Defaults to False
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Offset: An offset required to add to region to move it inside window_region.
|
Offset: An offset required to add to region to move it inside window_region.
|
||||||
@@ -327,7 +330,7 @@ class Region(NamedTuple):
|
|||||||
|
|
||||||
window_left, window_top, window_right, window_bottom = window_region.corners
|
window_left, window_top, window_right, window_bottom = window_region.corners
|
||||||
region = region.crop_size(window_region.size)
|
region = region.crop_size(window_region.size)
|
||||||
left, top, right, bottom = region.corners
|
left, top_, right, bottom = region.corners
|
||||||
delta_x = delta_y = 0
|
delta_x = delta_y = 0
|
||||||
|
|
||||||
if not (
|
if not (
|
||||||
@@ -343,15 +346,18 @@ class Region(NamedTuple):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if not (
|
if not (
|
||||||
(window_bottom > top >= window_top)
|
(window_bottom > top_ >= window_top)
|
||||||
and (window_bottom > bottom >= window_top)
|
and (window_bottom > bottom >= window_top)
|
||||||
):
|
):
|
||||||
# The window needs to scroll on the Y axis to bring region in to view
|
# The window needs to scroll on the Y axis to bring region in to view
|
||||||
delta_y = min(
|
if top:
|
||||||
top - window_top,
|
delta_y = top_ - window_top
|
||||||
top - (window_bottom - region.height),
|
else:
|
||||||
key=abs,
|
delta_y = min(
|
||||||
)
|
top_ - window_top,
|
||||||
|
top_ - (window_bottom - region.height),
|
||||||
|
key=abs,
|
||||||
|
)
|
||||||
return Offset(delta_x, delta_y)
|
return Offset(delta_x, delta_y)
|
||||||
|
|
||||||
def __bool__(self) -> bool:
|
def __bool__(self) -> bool:
|
||||||
|
|||||||
@@ -225,7 +225,7 @@ class Screen(Widget):
|
|||||||
return self._move_focus(-1)
|
return self._move_focus(-1)
|
||||||
|
|
||||||
def _reset_focus(
|
def _reset_focus(
|
||||||
self, widget: Widget, avoiding: list[DOMNode] | None = None
|
self, widget: Widget, avoiding: list[Widget] | None = None
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Reset the focus when a widget is removed
|
"""Reset the focus when a widget is removed
|
||||||
|
|
||||||
@@ -252,14 +252,20 @@ class Screen(Widget):
|
|||||||
# the focus chain.
|
# the focus chain.
|
||||||
widget_index = focusable_widgets.index(widget)
|
widget_index = focusable_widgets.index(widget)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
# Seems we can't find it. There's no good reason this should
|
# widget is not in focusable widgets
|
||||||
# happen but, on the off-chance, let's go into a "no focus" state.
|
# It may have been made invisible
|
||||||
self.set_focus(None)
|
# Move to a sibling if possible
|
||||||
|
for sibling in widget.visible_siblings:
|
||||||
|
if sibling not in avoiding and sibling.can_focus:
|
||||||
|
self.set_focus(sibling)
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
self.set_focus(None)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Now go looking for something before it, that isn't about to be
|
# Now go looking for something before it, that isn't about to be
|
||||||
# removed, and which can receive focus, and go focus that.
|
# removed, and which can receive focus, and go focus that.
|
||||||
chosen: DOMNode | None = None
|
chosen: Widget | None = None
|
||||||
for candidate in reversed(
|
for candidate in reversed(
|
||||||
focusable_widgets[widget_index + 1 :] + focusable_widgets[:widget_index]
|
focusable_widgets[widget_index + 1 :] + focusable_widgets[:widget_index]
|
||||||
):
|
):
|
||||||
@@ -368,6 +374,7 @@ class Screen(Widget):
|
|||||||
hidden, shown, resized = self._compositor.reflow(self, size)
|
hidden, shown, resized = self._compositor.reflow(self, size)
|
||||||
Hide = events.Hide
|
Hide = events.Hide
|
||||||
Show = events.Show
|
Show = events.Show
|
||||||
|
|
||||||
for widget in hidden:
|
for widget in hidden:
|
||||||
widget.post_message_no_wait(Hide(self))
|
widget.post_message_no_wait(Hide(self))
|
||||||
for widget in shown:
|
for widget in shown:
|
||||||
|
|||||||
@@ -238,7 +238,6 @@ class Widget(DOMNode):
|
|||||||
auto_width = Reactive(True)
|
auto_width = Reactive(True)
|
||||||
auto_height = Reactive(True)
|
auto_height = Reactive(True)
|
||||||
has_focus = Reactive(False)
|
has_focus = Reactive(False)
|
||||||
descendant_has_focus = Reactive(False)
|
|
||||||
mouse_over = Reactive(False)
|
mouse_over = Reactive(False)
|
||||||
scroll_x = Reactive(0.0, repaint=False, layout=False)
|
scroll_x = Reactive(0.0, repaint=False, layout=False)
|
||||||
scroll_y = Reactive(0.0, repaint=False, layout=False)
|
scroll_y = Reactive(0.0, repaint=False, layout=False)
|
||||||
@@ -262,6 +261,18 @@ class Widget(DOMNode):
|
|||||||
else:
|
else:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
@property
|
||||||
|
def visible_siblings(self) -> list[Widget]:
|
||||||
|
"""A list of siblings which will be shown.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list[Widget]: List of siblings.
|
||||||
|
"""
|
||||||
|
siblings = [
|
||||||
|
widget for widget in self.siblings if widget.visible and widget.display
|
||||||
|
]
|
||||||
|
return siblings
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def allow_vertical_scroll(self) -> bool:
|
def allow_vertical_scroll(self) -> bool:
|
||||||
"""Check if vertical scroll is permitted.
|
"""Check if vertical scroll is permitted.
|
||||||
@@ -1345,6 +1356,7 @@ class Widget(DOMNode):
|
|||||||
animate: bool = True,
|
animate: bool = True,
|
||||||
speed: float | None = None,
|
speed: float | None = None,
|
||||||
duration: float | None = None,
|
duration: float | None = None,
|
||||||
|
top: bool = False,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Scroll scrolling to bring a widget in to view.
|
"""Scroll scrolling to bring a widget in to view.
|
||||||
|
|
||||||
@@ -1370,6 +1382,7 @@ class Widget(DOMNode):
|
|||||||
animate=animate,
|
animate=animate,
|
||||||
speed=speed,
|
speed=speed,
|
||||||
duration=duration,
|
duration=duration,
|
||||||
|
top=top,
|
||||||
)
|
)
|
||||||
if scroll_offset:
|
if scroll_offset:
|
||||||
scrolled = True
|
scrolled = True
|
||||||
@@ -1396,6 +1409,7 @@ class Widget(DOMNode):
|
|||||||
animate: bool = True,
|
animate: bool = True,
|
||||||
speed: float | None = None,
|
speed: float | None = None,
|
||||||
duration: float | None = None,
|
duration: float | None = None,
|
||||||
|
top: bool = False,
|
||||||
) -> Offset:
|
) -> Offset:
|
||||||
"""Scrolls a given region in to view, if required.
|
"""Scrolls a given region in to view, if required.
|
||||||
|
|
||||||
@@ -1408,6 +1422,7 @@ class Widget(DOMNode):
|
|||||||
animate (bool, optional): True to animate, or False to jump. Defaults to True.
|
animate (bool, optional): True to animate, or False to jump. Defaults to True.
|
||||||
speed (float | None, optional): Speed of scroll if animate is True. Or None to use duration.
|
speed (float | None, optional): Speed of scroll if animate is True. Or None to use duration.
|
||||||
duration (float | None, optional): Duration of animation, if animate is True and speed is None.
|
duration (float | None, optional): Duration of animation, if animate is True and speed is None.
|
||||||
|
top (bool, optional): Scroll region to top of container. Defaults to False.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Offset: The distance that was scrolled.
|
Offset: The distance that was scrolled.
|
||||||
@@ -1419,7 +1434,7 @@ class Widget(DOMNode):
|
|||||||
if window in region:
|
if window in region:
|
||||||
return Offset()
|
return Offset()
|
||||||
|
|
||||||
delta_x, delta_y = Region.get_scroll_to_visible(window, region)
|
delta_x, delta_y = Region.get_scroll_to_visible(window, region, top=top)
|
||||||
scroll_x, scroll_y = self.scroll_offset
|
scroll_x, scroll_y = self.scroll_offset
|
||||||
delta = Offset(
|
delta = Offset(
|
||||||
clamp(scroll_x + delta_x, 0, self.max_scroll_x) - scroll_x,
|
clamp(scroll_x + delta_x, 0, self.max_scroll_x) - scroll_x,
|
||||||
@@ -1440,8 +1455,10 @@ class Widget(DOMNode):
|
|||||||
def scroll_visible(
|
def scroll_visible(
|
||||||
self,
|
self,
|
||||||
animate: bool = True,
|
animate: bool = True,
|
||||||
|
*,
|
||||||
speed: float | None = None,
|
speed: float | None = None,
|
||||||
duration: float | None = None,
|
duration: float | None = None,
|
||||||
|
top: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Scroll the container to make this widget visible.
|
"""Scroll the container to make this widget visible.
|
||||||
|
|
||||||
@@ -1449,6 +1466,7 @@ class Widget(DOMNode):
|
|||||||
animate (bool, optional): _description_. Defaults to True.
|
animate (bool, optional): _description_. Defaults to True.
|
||||||
speed (float | None, optional): _description_. Defaults to None.
|
speed (float | None, optional): _description_. Defaults to None.
|
||||||
duration (float | None, optional): _description_. Defaults to None.
|
duration (float | None, optional): _description_. Defaults to None.
|
||||||
|
top (bool, optional): Scroll to top of container. Defaults to False.
|
||||||
"""
|
"""
|
||||||
parent = self.parent
|
parent = self.parent
|
||||||
if isinstance(parent, Widget):
|
if isinstance(parent, Widget):
|
||||||
@@ -1458,6 +1476,7 @@ class Widget(DOMNode):
|
|||||||
animate=animate,
|
animate=animate,
|
||||||
speed=speed,
|
speed=speed,
|
||||||
duration=duration,
|
duration=duration,
|
||||||
|
top=top,
|
||||||
)
|
)
|
||||||
|
|
||||||
def __init_subclass__(
|
def __init_subclass__(
|
||||||
@@ -1569,8 +1588,18 @@ class Widget(DOMNode):
|
|||||||
yield "hover"
|
yield "hover"
|
||||||
if self.has_focus:
|
if self.has_focus:
|
||||||
yield "focus"
|
yield "focus"
|
||||||
if self.descendant_has_focus:
|
try:
|
||||||
yield "focus-within"
|
focused = self.screen.focused
|
||||||
|
except NoScreen:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
if focused:
|
||||||
|
node = focused
|
||||||
|
while node is not None:
|
||||||
|
if node is self:
|
||||||
|
yield "focus-within"
|
||||||
|
break
|
||||||
|
node = node._parent
|
||||||
|
|
||||||
def post_render(self, renderable: RenderableType) -> ConsoleRenderable:
|
def post_render(self, renderable: RenderableType) -> ConsoleRenderable:
|
||||||
"""Applies style attributes to the default renderable.
|
"""Applies style attributes to the default renderable.
|
||||||
@@ -1919,27 +1948,18 @@ class Widget(DOMNode):
|
|||||||
self.mouse_over = True
|
self.mouse_over = True
|
||||||
|
|
||||||
def _on_focus(self, event: events.Focus) -> None:
|
def _on_focus(self, event: events.Focus) -> None:
|
||||||
self.emit_no_wait(events.DescendantFocus(self))
|
for node in self.ancestors:
|
||||||
|
if node._has_focus_within:
|
||||||
|
self.app.update_styles(node)
|
||||||
self.has_focus = True
|
self.has_focus = True
|
||||||
self.refresh()
|
self.refresh()
|
||||||
|
|
||||||
def _on_blur(self, event: events.Blur) -> None:
|
def _on_blur(self, event: events.Blur) -> None:
|
||||||
self.emit_no_wait(events.DescendantBlur(self))
|
if any(node._has_focus_within for node in self.ancestors):
|
||||||
|
self.app.update_styles(self)
|
||||||
self.has_focus = False
|
self.has_focus = False
|
||||||
self.refresh()
|
self.refresh()
|
||||||
|
|
||||||
def _on_descendant_focus(self, event: events.DescendantFocus) -> None:
|
|
||||||
if not self.descendant_has_focus:
|
|
||||||
self.descendant_has_focus = True
|
|
||||||
|
|
||||||
def _on_descendant_blur(self, event: events.DescendantBlur) -> None:
|
|
||||||
if self.descendant_has_focus:
|
|
||||||
self.descendant_has_focus = False
|
|
||||||
|
|
||||||
def watch_descendant_has_focus(self, value: bool) -> None:
|
|
||||||
if "focus-within" in self.pseudo_classes:
|
|
||||||
self.app._require_stylesheet_update.add(self)
|
|
||||||
|
|
||||||
def _on_mouse_scroll_down(self, event) -> None:
|
def _on_mouse_scroll_down(self, event) -> None:
|
||||||
if self.allow_vertical_scroll:
|
if self.allow_vertical_scroll:
|
||||||
if self.scroll_down(animate=False):
|
if self.scroll_down(animate=False):
|
||||||
|
|||||||
@@ -58,8 +58,8 @@ class HeaderTitle(Widget):
|
|||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
text: Reactive[str] = Reactive("Hello World")
|
text: Reactive[str] = Reactive("")
|
||||||
sub_text = Reactive("Test")
|
sub_text = Reactive("")
|
||||||
|
|
||||||
def render(self) -> Text:
|
def render(self) -> Text:
|
||||||
text = Text(self.text, no_wrap=True, overflow="ellipsis")
|
text = Text(self.text, no_wrap=True, overflow="ellipsis")
|
||||||
@@ -89,9 +89,9 @@ class Header(Widget):
|
|||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
tall = Reactive(True)
|
tall = Reactive(False)
|
||||||
|
|
||||||
DEFAULT_CLASSES = "-tall"
|
DEFAULT_CLASSES = ""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ class TextLog(ScrollView, can_focus=True):
|
|||||||
min_width: var[int] = var(78)
|
min_width: var[int] = var(78)
|
||||||
wrap: var[bool] = var(False)
|
wrap: var[bool] = var(False)
|
||||||
highlight: var[bool] = var(False)
|
highlight: var[bool] = var(False)
|
||||||
|
markup: var[bool] = var(False)
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -38,6 +39,7 @@ class TextLog(ScrollView, can_focus=True):
|
|||||||
min_width: int = 78,
|
min_width: int = 78,
|
||||||
wrap: bool = False,
|
wrap: bool = False,
|
||||||
highlight: bool = False,
|
highlight: bool = False,
|
||||||
|
markup: bool = False,
|
||||||
name: str | None = None,
|
name: str | None = None,
|
||||||
id: str | None = None,
|
id: str | None = None,
|
||||||
classes: str | None = None,
|
classes: str | None = None,
|
||||||
@@ -51,6 +53,7 @@ class TextLog(ScrollView, can_focus=True):
|
|||||||
self.min_width = min_width
|
self.min_width = min_width
|
||||||
self.wrap = wrap
|
self.wrap = wrap
|
||||||
self.highlight = highlight
|
self.highlight = highlight
|
||||||
|
self.markup = markup
|
||||||
self.highlighter = ReprHighlighter()
|
self.highlighter = ReprHighlighter()
|
||||||
|
|
||||||
def _on_styles_updated(self) -> None:
|
def _on_styles_updated(self) -> None:
|
||||||
@@ -68,6 +71,8 @@ class TextLog(ScrollView, can_focus=True):
|
|||||||
renderable = Pretty(content)
|
renderable = Pretty(content)
|
||||||
else:
|
else:
|
||||||
if isinstance(content, str):
|
if isinstance(content, str):
|
||||||
|
if self.markup:
|
||||||
|
content = Text.from_markup(content)
|
||||||
if self.highlight:
|
if self.highlight:
|
||||||
renderable = self.highlighter(content)
|
renderable = self.highlighter(content)
|
||||||
else:
|
else:
|
||||||
@@ -81,8 +86,9 @@ class TextLog(ScrollView, can_focus=True):
|
|||||||
render_options = console.options.update_width(width)
|
render_options = console.options.update_width(width)
|
||||||
if not self.wrap:
|
if not self.wrap:
|
||||||
render_options = render_options.update(overflow="ignore", no_wrap=True)
|
render_options = render_options.update(overflow="ignore", no_wrap=True)
|
||||||
segments = self.app.console.render(renderable, render_options)
|
segments = self.app.console.render(renderable, render_options.update_width(80))
|
||||||
lines = list(Segment.split_lines(segments))
|
lines = list(Segment.split_lines(segments))
|
||||||
|
|
||||||
self.max_width = max(
|
self.max_width = max(
|
||||||
self.max_width,
|
self.max_width,
|
||||||
max(sum(segment.cell_length for segment in _line) for _line in lines),
|
max(sum(segment.cell_length for segment in _line) for _line in lines),
|
||||||
@@ -102,7 +108,9 @@ class TextLog(ScrollView, can_focus=True):
|
|||||||
|
|
||||||
def render_line(self, y: int) -> list[Segment]:
|
def render_line(self, y: int) -> list[Segment]:
|
||||||
scroll_x, scroll_y = self.scroll_offset
|
scroll_x, scroll_y = self.scroll_offset
|
||||||
return self._render_line(scroll_y + y, scroll_x, self.size.width)
|
line = self._render_line(scroll_y + y, scroll_x, self.size.width)
|
||||||
|
line = list(Segment.apply_style(line, self.rich_style))
|
||||||
|
return line
|
||||||
|
|
||||||
def render_lines(self, crop: Region) -> Lines:
|
def render_lines(self, crop: Region) -> Lines:
|
||||||
"""Render the widget in to lines.
|
"""Render the widget in to lines.
|
||||||
|
|||||||
Reference in New Issue
Block a user