Merge pull request #959 from Textualize/demo

adds demo
This commit is contained in:
Will McGugan
2022-10-21 09:48:31 +01:00
committed by GitHub
26 changed files with 843 additions and 71 deletions

View File

@@ -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

View File

@@ -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
```

View File

@@ -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."""

View File

@@ -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
View File

@@ -0,0 +1,6 @@
from .demo import DemoApp
if __name__ == "__main__":
app = DemoApp()
app.run()

View File

@@ -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 = (

View File

@@ -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:

View File

@@ -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)

View File

@@ -56,6 +56,7 @@ class BorderApp(App):
event.button.id,
self.stylesheet._variables["secondary"],
)
self.bell()
app = BorderApp()

View File

@@ -1,6 +1,3 @@
* {
transition: color 300ms linear, background 300ms linear;
}
ColorButtons {
dock: left;

View File

@@ -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()

View File

@@ -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():

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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,

View File

@@ -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
View 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
View 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()

View File

@@ -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:

View File

@@ -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

View File

@@ -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:

View File

@@ -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:

View File

@@ -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):

View File

@@ -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,

View File

@@ -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.