diff --git a/docs/getting_started.md b/docs/getting_started.md index f9446456a..9ee3e138a 100644 --- a/docs/getting_started.md +++ b/docs/getting_started.md @@ -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 diff --git a/docs/tutorial.md b/docs/tutorial.md index 52d12df70..0bfa8aa09 100644 --- a/docs/tutorial.md +++ b/docs/tutorial.md @@ -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 ``` diff --git a/examples/five_by_five.py b/examples/five_by_five.py index 5d4c71fe3..84a50d639 100644 --- a/examples/five_by_five.py +++ b/examples/five_by_five.py @@ -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.""" diff --git a/pyproject.toml b/pyproject.toml index 378015988..cc3482a85 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 "] diff --git a/src/textual/__main__.py b/src/textual/__main__.py new file mode 100644 index 000000000..de8c1b79f --- /dev/null +++ b/src/textual/__main__.py @@ -0,0 +1,6 @@ +from .demo import DemoApp + + +if __name__ == "__main__": + app = DemoApp() + app.run() diff --git a/src/textual/_animator.py b/src/textual/_animator.py index b628a23f7..0b8ebb1ab 100644 --- a/src/textual/_animator.py +++ b/src/textual/_animator.py @@ -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 = ( diff --git a/src/textual/app.py b/src/textual/app.py index 027fda549..af26d2f44 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -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: diff --git a/src/textual/box_model.py b/src/textual/box_model.py index adfc49c36..7271bef54 100644 --- a/src/textual/box_model.py +++ b/src/textual/box_model.py @@ -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) diff --git a/src/textual/cli/previews/borders.py b/src/textual/cli/previews/borders.py index 55b3cedf6..613343443 100644 --- a/src/textual/cli/previews/borders.py +++ b/src/textual/cli/previews/borders.py @@ -56,6 +56,7 @@ class BorderApp(App): event.button.id, self.stylesheet._variables["secondary"], ) + self.bell() app = BorderApp() diff --git a/src/textual/cli/previews/colors.css b/src/textual/cli/previews/colors.css index f06637893..3af8eabd7 100644 --- a/src/textual/cli/previews/colors.css +++ b/src/textual/cli/previews/colors.css @@ -1,6 +1,3 @@ - * { - transition: color 300ms linear, background 300ms linear; -} ColorButtons { dock: left; diff --git a/src/textual/cli/previews/colors.py b/src/textual/cli/previews/colors.py index 1c5ce0bad..5edd4050e 100644 --- a/src/textual/cli/previews/colors.py +++ b/src/textual/cli/previews/colors.py @@ -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() diff --git a/src/textual/cli/previews/easing.py b/src/textual/cli/previews/easing.py index 795164b6c..6edcdb82a 100644 --- a/src/textual/cli/previews/easing.py +++ b/src/textual/cli/previews/easing.py @@ -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(): diff --git a/src/textual/css/_style_properties.py b/src/textual/css/_style_properties.py index 6a2bffe52..5770add70 100644 --- a/src/textual/css/_style_properties.py +++ b/src/textual/css/_style_properties.py @@ -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) diff --git a/src/textual/css/parse.py b/src/textual/css/parse.py index b26f171db..fdfca677a 100644 --- a/src/textual/css/parse.py +++ b/src/textual/css/parse.py @@ -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 diff --git a/src/textual/css/scalar_animation.py b/src/textual/css/scalar_animation.py index c584ef692..697f94405 100644 --- a/src/textual/css/scalar_animation.py +++ b/src/textual/css/scalar_animation.py @@ -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 diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py index dd4b8d4b6..d830b6774 100644 --- a/src/textual/css/styles.py +++ b/src/textual/css/styles.py @@ -610,7 +610,6 @@ class Styles(StylesBase): list[tuple[str, Specificity6, Any]]]: A list containing a tuple of , . """ 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, diff --git a/src/textual/css/stylesheet.py b/src/textual/css/stylesheet.py index 13475f992..9d09835c6 100644 --- a/src/textual/css/stylesheet.py +++ b/src/textual/css/stylesheet.py @@ -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 diff --git a/src/textual/demo.css b/src/textual/demo.css new file mode 100644 index 000000000..260aab7cd --- /dev/null +++ b/src/textual/demo.css @@ -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; +} diff --git a/src/textual/demo.py b/src/textual/demo.py new file mode 100644 index 000000000..594e43ab2 --- /dev/null +++ b/src/textual/demo.py @@ -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() diff --git a/src/textual/design.py b/src/textual/design.py index 268bdb621..35650e9bf 100644 --- a/src/textual/design.py +++ b/src/textual/design.py @@ -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: diff --git a/src/textual/dom.py b/src/textual/dom.py index af48bf1df..1a35b0717 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -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 diff --git a/src/textual/geometry.py b/src/textual/geometry.py index d5e356766..55ad94ef7 100644 --- a/src/textual/geometry.py +++ b/src/textual/geometry.py @@ -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: diff --git a/src/textual/screen.py b/src/textual/screen.py index 2f110c2ff..70b971125 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -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: diff --git a/src/textual/widget.py b/src/textual/widget.py index 518aefeb5..363c1f518 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -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): diff --git a/src/textual/widgets/_header.py b/src/textual/widgets/_header.py index b75418a1a..c4382906d 100644 --- a/src/textual/widgets/_header.py +++ b/src/textual/widgets/_header.py @@ -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, diff --git a/src/textual/widgets/_text_log.py b/src/textual/widgets/_text_log.py index 406914fb4..05f1f0d5e 100644 --- a/src/textual/widgets/_text_log.py +++ b/src/textual/widgets/_text_log.py @@ -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.