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.
|
||||
|
||||
```
|
||||
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:
|
||||
|
||||
```
|
||||
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
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ If you want to try the finished Stopwatch app and follow along with the code, fi
|
||||
=== "GitHub CLI"
|
||||
|
||||
```bash
|
||||
gh repo clone Textualize/textual
|
||||
gh repo clone -b css Textualize/textual
|
||||
```
|
||||
|
||||
|
||||
|
||||
@@ -317,9 +317,8 @@ class FiveByFive(App[None]):
|
||||
#: App-level bindings.
|
||||
BINDINGS = [("ctrl+d", "toggle_dark", "Toggle Dark Mode")]
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Constructor."""
|
||||
super().__init__(title="5x5 -- A little annoying puzzle")
|
||||
# Set the title
|
||||
TITLE = "5x5 -- A little annoying puzzle"
|
||||
|
||||
def on_mount(self) -> None:
|
||||
"""Set up the application on startup."""
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "textual"
|
||||
version = "0.2.0b7"
|
||||
version = "0.2.0b8"
|
||||
homepage = "https://github.com/Textualize/textual"
|
||||
description = "Modern Text User Interface framework"
|
||||
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(
|
||||
self.end_value, (int, float)
|
||||
), f"`end_value` must be float, not {self.end_value!r}"
|
||||
|
||||
if self.end_value > self.start_value:
|
||||
eased_factor = self.easing(factor)
|
||||
value = (
|
||||
|
||||
@@ -140,18 +140,18 @@ class App(Generic[ReturnType], DOMNode):
|
||||
"""
|
||||
|
||||
SCREENS: dict[str, Screen] = {}
|
||||
|
||||
_BASE_PATH: str | None = 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("")
|
||||
dark: Reactive[bool] = Reactive(True)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
driver_class: Type[Driver] | None = None,
|
||||
title: str | None = None,
|
||||
css_path: CSSPathType = None,
|
||||
watch_css: bool = False,
|
||||
):
|
||||
@@ -190,10 +190,10 @@ class App(Generic[ReturnType], DOMNode):
|
||||
self._animator = Animator(self)
|
||||
self._animate = self._animator.bind(self)
|
||||
self.mouse_position = Offset(0, 0)
|
||||
if title is None:
|
||||
self.title = f"{self.__class__.__name__}"
|
||||
else:
|
||||
self.title = title
|
||||
self.title = (
|
||||
self.TITLE if self.TITLE is not None else f"{self.__class__.__name__}"
|
||||
)
|
||||
self.sub_title = self.SUB_TITLE if self.SUB_TITLE is not None else ""
|
||||
|
||||
self._logger = Logger(self._log)
|
||||
|
||||
@@ -480,7 +480,7 @@ class App(Generic[ReturnType], DOMNode):
|
||||
"""Action to toggle dark mode."""
|
||||
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.
|
||||
|
||||
Args:
|
||||
@@ -1345,6 +1345,7 @@ class App(Generic[ReturnType], DOMNode):
|
||||
Returns:
|
||||
bool: True if the event has handled.
|
||||
"""
|
||||
print("ACTION", action, default_namespace)
|
||||
if isinstance(action, str):
|
||||
target, params = actions.parse(action)
|
||||
else:
|
||||
|
||||
@@ -80,6 +80,8 @@ def get_box_model(
|
||||
max_width = styles.max_width.resolve_dimension(
|
||||
content_container, viewport, fraction_unit
|
||||
)
|
||||
if is_border_box:
|
||||
max_width -= gutter.width
|
||||
content_width = min(content_width, max_width)
|
||||
|
||||
content_width = max(Fraction(0), content_width)
|
||||
|
||||
@@ -56,6 +56,7 @@ class BorderApp(App):
|
||||
event.button.id,
|
||||
self.stylesheet._variables["secondary"],
|
||||
)
|
||||
self.bell()
|
||||
|
||||
|
||||
app = BorderApp()
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
* {
|
||||
transition: color 300ms linear, background 300ms linear;
|
||||
}
|
||||
|
||||
ColorButtons {
|
||||
dock: left;
|
||||
|
||||
@@ -78,10 +78,11 @@ class ColorsApp(App):
|
||||
content.mount(ColorsView())
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
self.bell()
|
||||
self.query(ColorGroup).remove_class("-active")
|
||||
group = self.query_one(f"#group-{event.button.id}", ColorGroup)
|
||||
group.add_class("-active")
|
||||
group.scroll_visible(speed=150)
|
||||
group.scroll_visible(top=True, speed=150)
|
||||
|
||||
|
||||
app = ColorsApp()
|
||||
|
||||
@@ -84,6 +84,7 @@ class EasingApp(App):
|
||||
)
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
self.bell()
|
||||
self.animated_bar.animation_running = True
|
||||
|
||||
def _animation_complete():
|
||||
|
||||
@@ -856,6 +856,7 @@ class ColorProperty:
|
||||
elif isinstance(color, Color):
|
||||
if obj.set_rule(self.name, color):
|
||||
obj.refresh(children=self._is_background)
|
||||
|
||||
elif isinstance(color, str):
|
||||
alpha = 1.0
|
||||
parsed_color = Color(255, 255, 255)
|
||||
|
||||
@@ -36,6 +36,10 @@ SELECTOR_MAP: dict[str, tuple[SelectorType, Specificity3]] = {
|
||||
|
||||
@lru_cache(maxsize=1024)
|
||||
def parse_selectors(css_selectors: str) -> tuple[SelectorSet, ...]:
|
||||
|
||||
if not css_selectors.strip():
|
||||
return ()
|
||||
|
||||
tokens = iter(tokenize(css_selectors, ""))
|
||||
|
||||
get_selector = SELECTOR_MAP.get
|
||||
|
||||
@@ -58,10 +58,13 @@ class ScalarAnimation(Animation):
|
||||
setattr(self.styles, self.attribute, self.final_value)
|
||||
return True
|
||||
|
||||
offset = self.start + (self.destination - self.start) * eased_factor
|
||||
current = self.styles._rules[self.attribute]
|
||||
if current != offset:
|
||||
setattr(self.styles, f"{self.attribute}", offset)
|
||||
if hasattr(self.start, "blend"):
|
||||
value = self.start.blend(self.destination, eased_factor)
|
||||
else:
|
||||
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
|
||||
|
||||
|
||||
@@ -610,7 +610,6 @@ class Styles(StylesBase):
|
||||
list[tuple[str, Specificity6, Any]]]: A list containing a tuple of <RULE NAME>, <SPECIFICITY> <RULE VALUE>.
|
||||
"""
|
||||
is_important = self.important.__contains__
|
||||
|
||||
rules = [
|
||||
(
|
||||
rule_name,
|
||||
@@ -644,9 +643,6 @@ class Styles(StylesBase):
|
||||
easing: EasingFunction,
|
||||
on_complete: CallbackType | None = None,
|
||||
) -> ScalarAnimation | None:
|
||||
# from ..widget import Widget
|
||||
# node = self.node
|
||||
# assert isinstance(self.node, Widget)
|
||||
if isinstance(value, ScalarOffset):
|
||||
return ScalarAnimation(
|
||||
self.node,
|
||||
|
||||
@@ -370,11 +370,14 @@ class Stylesheet:
|
||||
|
||||
# Collect the rules defined in the stylesheet
|
||||
node._has_hover_style = False
|
||||
node._has_focus_within = False
|
||||
for rule in rules:
|
||||
is_default_rules = rule.is_default_rules
|
||||
tie_breaker = rule.tie_breaker
|
||||
if ":hover" in rule.selector_names:
|
||||
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 key, rule_specificity, value in rule.styles.extract_rules(
|
||||
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)
|
||||
|
||||
if self.panel is None:
|
||||
panel = surface.blend(primary, 0.1)
|
||||
panel = surface.blend(primary, 0.1, alpha=1)
|
||||
if dark:
|
||||
panel += boost
|
||||
else:
|
||||
@@ -154,7 +154,7 @@ class ColorSystem:
|
||||
yield (f"{label}{'-' + str(abs(n)) if n else ''}"), n * luminosity_step
|
||||
|
||||
# Color names and color
|
||||
COLORS = [
|
||||
COLORS: list[tuple[str, Color]] = [
|
||||
("primary", primary),
|
||||
("secondary", secondary),
|
||||
("primary-background", primary),
|
||||
@@ -178,9 +178,9 @@ class ColorSystem:
|
||||
spread = luminosity_spread
|
||||
for shade_name, luminosity_delta in luminosity_range(spread):
|
||||
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(
|
||||
WHITE, spread + luminosity_delta
|
||||
WHITE, spread + luminosity_delta, alpha=1.0
|
||||
).clamped
|
||||
colors[f"{name}{shade_name}"] = shade_color.hex
|
||||
else:
|
||||
|
||||
@@ -140,6 +140,7 @@ class DOMNode(MessagePump):
|
||||
self._css_types = {cls.__name__ for cls in self._css_bases(self.__class__)}
|
||||
self._bindings = Bindings(self.BINDINGS)
|
||||
self._has_hover_style: bool = False
|
||||
self._has_focus_within: bool = False
|
||||
|
||||
super().__init__()
|
||||
|
||||
@@ -277,7 +278,7 @@ class DOMNode(MessagePump):
|
||||
while node and not isinstance(node, Screen):
|
||||
node = node._parent
|
||||
if not isinstance(node, Screen):
|
||||
raise NoScreen(f"{self} has no screen")
|
||||
raise NoScreen("node has no screen")
|
||||
return node
|
||||
|
||||
@property
|
||||
|
||||
@@ -307,7 +307,9 @@ class Region(NamedTuple):
|
||||
return cls(x, y, width, height)
|
||||
|
||||
@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
|
||||
another region.
|
||||
|
||||
@@ -316,6 +318,7 @@ class Region(NamedTuple):
|
||||
Args:
|
||||
window_region (Region): The window region.
|
||||
region (Region): The region to move inside the window.
|
||||
top (bool, optional): Get offset to top of window. Defaults to False
|
||||
|
||||
Returns:
|
||||
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
|
||||
region = region.crop_size(window_region.size)
|
||||
left, top, right, bottom = region.corners
|
||||
left, top_, right, bottom = region.corners
|
||||
delta_x = delta_y = 0
|
||||
|
||||
if not (
|
||||
@@ -343,15 +346,18 @@ class Region(NamedTuple):
|
||||
)
|
||||
|
||||
if not (
|
||||
(window_bottom > top >= window_top)
|
||||
(window_bottom > top_ >= window_top)
|
||||
and (window_bottom > bottom >= window_top)
|
||||
):
|
||||
# The window needs to scroll on the Y axis to bring region in to view
|
||||
delta_y = min(
|
||||
top - window_top,
|
||||
top - (window_bottom - region.height),
|
||||
key=abs,
|
||||
)
|
||||
if top:
|
||||
delta_y = top_ - window_top
|
||||
else:
|
||||
delta_y = min(
|
||||
top_ - window_top,
|
||||
top_ - (window_bottom - region.height),
|
||||
key=abs,
|
||||
)
|
||||
return Offset(delta_x, delta_y)
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
|
||||
@@ -225,7 +225,7 @@ class Screen(Widget):
|
||||
return self._move_focus(-1)
|
||||
|
||||
def _reset_focus(
|
||||
self, widget: Widget, avoiding: list[DOMNode] | None = None
|
||||
self, widget: Widget, avoiding: list[Widget] | None = None
|
||||
) -> None:
|
||||
"""Reset the focus when a widget is removed
|
||||
|
||||
@@ -252,14 +252,20 @@ class Screen(Widget):
|
||||
# the focus chain.
|
||||
widget_index = focusable_widgets.index(widget)
|
||||
except ValueError:
|
||||
# Seems we can't find it. There's no good reason this should
|
||||
# happen but, on the off-chance, let's go into a "no focus" state.
|
||||
self.set_focus(None)
|
||||
# widget is not in focusable widgets
|
||||
# It may have been made invisible
|
||||
# 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
|
||||
|
||||
# Now go looking for something before it, that isn't about to be
|
||||
# removed, and which can receive focus, and go focus that.
|
||||
chosen: DOMNode | None = None
|
||||
chosen: Widget | None = None
|
||||
for candidate in reversed(
|
||||
focusable_widgets[widget_index + 1 :] + focusable_widgets[:widget_index]
|
||||
):
|
||||
@@ -368,6 +374,7 @@ class Screen(Widget):
|
||||
hidden, shown, resized = self._compositor.reflow(self, size)
|
||||
Hide = events.Hide
|
||||
Show = events.Show
|
||||
|
||||
for widget in hidden:
|
||||
widget.post_message_no_wait(Hide(self))
|
||||
for widget in shown:
|
||||
|
||||
@@ -238,7 +238,6 @@ class Widget(DOMNode):
|
||||
auto_width = Reactive(True)
|
||||
auto_height = Reactive(True)
|
||||
has_focus = Reactive(False)
|
||||
descendant_has_focus = Reactive(False)
|
||||
mouse_over = Reactive(False)
|
||||
scroll_x = Reactive(0.0, repaint=False, layout=False)
|
||||
scroll_y = Reactive(0.0, repaint=False, layout=False)
|
||||
@@ -262,6 +261,18 @@ class Widget(DOMNode):
|
||||
else:
|
||||
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
|
||||
def allow_vertical_scroll(self) -> bool:
|
||||
"""Check if vertical scroll is permitted.
|
||||
@@ -1345,6 +1356,7 @@ class Widget(DOMNode):
|
||||
animate: bool = True,
|
||||
speed: float | None = None,
|
||||
duration: float | None = None,
|
||||
top: bool = False,
|
||||
) -> bool:
|
||||
"""Scroll scrolling to bring a widget in to view.
|
||||
|
||||
@@ -1370,6 +1382,7 @@ class Widget(DOMNode):
|
||||
animate=animate,
|
||||
speed=speed,
|
||||
duration=duration,
|
||||
top=top,
|
||||
)
|
||||
if scroll_offset:
|
||||
scrolled = True
|
||||
@@ -1396,6 +1409,7 @@ class Widget(DOMNode):
|
||||
animate: bool = True,
|
||||
speed: float | None = None,
|
||||
duration: float | None = None,
|
||||
top: bool = False,
|
||||
) -> Offset:
|
||||
"""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.
|
||||
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.
|
||||
top (bool, optional): Scroll region to top of container. Defaults to False.
|
||||
|
||||
Returns:
|
||||
Offset: The distance that was scrolled.
|
||||
@@ -1419,7 +1434,7 @@ class Widget(DOMNode):
|
||||
if window in region:
|
||||
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
|
||||
delta = Offset(
|
||||
clamp(scroll_x + delta_x, 0, self.max_scroll_x) - scroll_x,
|
||||
@@ -1440,8 +1455,10 @@ class Widget(DOMNode):
|
||||
def scroll_visible(
|
||||
self,
|
||||
animate: bool = True,
|
||||
*,
|
||||
speed: float | None = None,
|
||||
duration: float | None = None,
|
||||
top: bool = False,
|
||||
) -> None:
|
||||
"""Scroll the container to make this widget visible.
|
||||
|
||||
@@ -1449,6 +1466,7 @@ class Widget(DOMNode):
|
||||
animate (bool, optional): _description_. Defaults to True.
|
||||
speed (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
|
||||
if isinstance(parent, Widget):
|
||||
@@ -1458,6 +1476,7 @@ class Widget(DOMNode):
|
||||
animate=animate,
|
||||
speed=speed,
|
||||
duration=duration,
|
||||
top=top,
|
||||
)
|
||||
|
||||
def __init_subclass__(
|
||||
@@ -1569,8 +1588,18 @@ class Widget(DOMNode):
|
||||
yield "hover"
|
||||
if self.has_focus:
|
||||
yield "focus"
|
||||
if self.descendant_has_focus:
|
||||
yield "focus-within"
|
||||
try:
|
||||
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:
|
||||
"""Applies style attributes to the default renderable.
|
||||
@@ -1919,27 +1948,18 @@ class Widget(DOMNode):
|
||||
self.mouse_over = True
|
||||
|
||||
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.refresh()
|
||||
|
||||
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.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:
|
||||
if self.allow_vertical_scroll:
|
||||
if self.scroll_down(animate=False):
|
||||
|
||||
@@ -58,8 +58,8 @@ class HeaderTitle(Widget):
|
||||
}
|
||||
"""
|
||||
|
||||
text: Reactive[str] = Reactive("Hello World")
|
||||
sub_text = Reactive("Test")
|
||||
text: Reactive[str] = Reactive("")
|
||||
sub_text = Reactive("")
|
||||
|
||||
def render(self) -> Text:
|
||||
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__(
|
||||
self,
|
||||
|
||||
@@ -30,6 +30,7 @@ class TextLog(ScrollView, can_focus=True):
|
||||
min_width: var[int] = var(78)
|
||||
wrap: var[bool] = var(False)
|
||||
highlight: var[bool] = var(False)
|
||||
markup: var[bool] = var(False)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -38,6 +39,7 @@ class TextLog(ScrollView, can_focus=True):
|
||||
min_width: int = 78,
|
||||
wrap: bool = False,
|
||||
highlight: bool = False,
|
||||
markup: bool = False,
|
||||
name: str | None = None,
|
||||
id: str | None = None,
|
||||
classes: str | None = None,
|
||||
@@ -51,6 +53,7 @@ class TextLog(ScrollView, can_focus=True):
|
||||
self.min_width = min_width
|
||||
self.wrap = wrap
|
||||
self.highlight = highlight
|
||||
self.markup = markup
|
||||
self.highlighter = ReprHighlighter()
|
||||
|
||||
def _on_styles_updated(self) -> None:
|
||||
@@ -68,6 +71,8 @@ class TextLog(ScrollView, can_focus=True):
|
||||
renderable = Pretty(content)
|
||||
else:
|
||||
if isinstance(content, str):
|
||||
if self.markup:
|
||||
content = Text.from_markup(content)
|
||||
if self.highlight:
|
||||
renderable = self.highlighter(content)
|
||||
else:
|
||||
@@ -81,8 +86,9 @@ class TextLog(ScrollView, can_focus=True):
|
||||
render_options = console.options.update_width(width)
|
||||
if not self.wrap:
|
||||
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))
|
||||
|
||||
self.max_width = max(
|
||||
self.max_width,
|
||||
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]:
|
||||
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:
|
||||
"""Render the widget in to lines.
|
||||
|
||||
Reference in New Issue
Block a user