Repository: Textualize/textual

Issue:
Disable scrolling of parent when Select widget is focused
### Discussed in https://github.com/Textualize/textual/discussions/2953

<div type='discussions-op-text'>

<sup>Originally posted by **grimnexo** July 16, 2023</sup>
Hi All,

I have been using textual for the past few days and I really enjoy it and am excited to design applications with it for my day-to-day use. In learning the package I have run into a question (two included here technically) that I can't seem to find any information on. (Keep in mind I am definitely _not_ a master programmer as I have mostly worked with scripting to this point)

I've taken the package demo code and reduced it in order to reproduce the issue I am running into in an easily identifiable manner:

When the Select widget is clicked on by the user it scrolls the parent container to center Select widget on the screen -- and I can't find out how to disable that behavior.

The Select widget should have space on screen to display without scrolling, but no matter where the Select widget is, it scrolls to center it on screen rather that offset by the parent container's sizing

I also had a secondary related question for the Select widget such as how to make it _lose_ focus when the click is anywhere else on the screen - if you open it then use a quick access link you'll see that it stays opened at the bottom of the screen until a choice is made.

The issue:
![SelectWidgetScrollingIssue](https://github.com/Textualize/textual/assets/57557037/7d8184df-ea52-4d8a-a172-c09e0c455c2d)

The code:
<details>
<summary> The Python code [yourchoice].py </summary>

```PYTHON
from __future__ import annotations
from rich.console import RenderableType
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.containers import Container, ScrollableContainer
from textual.widgets import Button, Footer, Header, Select, Static, TextLog, Placeholder, Markdown

__ISSUE_MD__ = """
## Question #1 -- How do I make selecting the option list _NOT_ scroll the parent container
This issue is that even when the Select widget has space on screen to display, it still scrolls which mis-aligns 
the parent container on the screen. 

I am trying to find a way to keep the section aligned while still having the drop-down expand if needed 
to a certain degree, maybe _X_ items long or a max-height value

## Question #2 - What's the best way for the Select widget to _lose_ focus when the user clicks elsewhere?
Example:
    If you open the dropdown then click a quick-access link on the left the dropdown menu doesn't naturally close

What's the ideal way to close the list when _anything_ else is clicked?
(I am assuming by overriding the on_click function and checking for focus somehow)

"""

WELCOME_MD = """
## Textual Layout Issue / Question
**Hello**! Textual is amazing and I'm having so much fun learning it!
There are _so_ _many_ potential projects I can use it for!
"""

WIDGETS_MD = """
Textual widgets are _amazingly_ powerful interactive components.
I've been working on building my own -> That's where I found this and couldn't figure it
out
"""


class Welcome(Container):
    def compose(self) -> ComposeResult:
        yield 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(duration=0.5, top=True)


class Title(Static):
    pass


class Body(ScrollableContainer):
    pass


class QuickAccess(Container):
    pass


class AboveFold(Container):
    pass


class Section(Container):
    pass


class SectionTitle(Static):
    pass


class Column(Container):
    pass


class TextContent(Static):
    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, duration=0.5)
        self.app.add_note(f"Scrolling to [b]{self.reveal}[/b]")


class DemoApp(App[None]):
    CSS_PATH = "demo.css"
    TITLE = "Question - Is this a bug?"
    BINDINGS = [
        ("f1", "app.toggle_class('TextLog', '-hidden')", "Notes"),
        Binding("ctrl+c,ctrl+q", "app.quit", "Quit", show=True),
    ]

    # Overloaded the __init__ section so that we could enable watching the CSS file
    def __init__(self):
        super().__init__(watch_css=True)

    # I lef the note section in for debugging, but wasn't sure what exactly to log while modifying the CSS
    def add_note(self, renderable: RenderableType) -> None:
        self.query_one(TextLog).write(renderable)

    def compose(self) -> ComposeResult:
        yield Container(
            Header(show_clock=False),
            TextLog(classes="-hidden", wrap=False, highlight=True, markup=True),
            Body(
                QuickAccess(
                    LocationLink("Home", ".location-top"),
                    LocationLink("Bugged Section", ".location-widgets"),
                ),
                AboveFold(Welcome(), classes="location-top"),
                Column(
                    Section(
                        SectionTitle("Example Section Title"),
                        Markdown(WIDGETS_MD),
                        Placeholder("-- Example space of n+ other widgets and their children --"),
                        Select([("Example Selectable Option", 1)],
                               prompt="Please choose an option.. (And watch it scroll)"),
                        Markdown(__ISSUE_MD__, id="issue-markdown"),
                        id="bugged-section"
                    ),
                    classes="location-widgets location-first",
                )
            )
        )
        yield Footer()

    def on_mount(self) -> None:
        self.add_note("?? Question ?? is running")
        self.query_one("Welcome Button", Button).focus()


app = DemoApp()
if __name__ == "__main__":
    app.run()

```

</details>
<details>
<summary>The reduced CSS code: demo.css</summary>

```CSS
* {transition: background 500ms in_out_cubic, color 500ms in_out_cubic;}
Screen {
    layers: base overlay notes notifications;
    overflow: hidden;
}
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;
    overflow: hidden;
}
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;
}
QuickAccess {
    width: 30;
    dock: left;
}
LocationLink {
    margin: 1 0 0 1;
    height: 1;
    padding: 1 2;
    background: $boost;
    color: $text;
    box-sizing: content-box;
    content-align: center middle;
}
LocationLink:hover {
    background: $accent;
    color: $text;
    text-style: bold;
}
Message {
    margin: 0 1;
}
Tree {
    margin: 1 0;
}
Window>Static {
    width: auto;
}

#issue-markdown {
    margin: 4 4 4 4;
    dock: bottom;
}

#bugged-section {
    /* --
    Here we set the height to be below the center and this demonstrates the issue:
        Action: [ User focuses the Select object ]
        Result:
            The entire parent container scrolls when it has space on screen already and should not need to
    */
    height: auto;
    border: red;
}

Placeholder {
    /* -- This placeholder is meant to simulate other widgets of a collective X height -- */
    margin: 1 1 1 1;
    height: 10vh;
}

Select {
    /* Nothing done in this section - I've tried various fixes with no luck */
}

```
</details>
 
I have been looking for a while to see if I could find references to this or similar issues, but I didn't have any luck or was not putting two and two together.

If anyone is aware of other discussions related to this issue please point me towards them!

Thank you in advance for any insights you can provide!</div>


Discussion:
> I also had a secondary related question for the Select widget such as how to make it lose focus when the click is anywhere else on the screen - if you open it then use a quick access link you'll see that it stays opened at the bottom of the screen until a choice is made.

@grimnexo I'm just having a look at the main issue here. On this side issue, the easy way to do things here might be to simply unset focus when you scroll to a new section. So, perhaps, add `self.app.set_focus(None)` to `LocationLink.on_click`, like this:

```python
    def on_click(self) -> None:
        self.app.set_focus(None)
        self.app.query_one(self.reveal).scroll_visible(top=True, duration=0.5)
        self.app.add_note(f"Scrolling to [b]{self.reveal}[/b]")
```
Okay, awesome! 

I was assuming that there was a way to set/unset the focus but I must have missed it in the examples. Thanks!

As far as the main parent scrolling, I believe the issue is related to the Select widget being in a large scrolling container specifically. 

If you have the select widget in a container that is fixed to the visible space (i.e. like a separate screen object that doesn't scroll) this behaviour doesn't seem to happen as far as I've noticed.


Note to self: it looks like the main culprit is [this line in `Screen`](https://github.com/Textualize/textual/blob/cb81b6d7a69e0c769047904d25527d83175f8fe2/src/textual/screen.py#L526). If that line is commented out (not the fix, just as a test), then the example app above works as you'd expect. My working hypothesis is that the issue might be down to how `Widget.can_view` decides if a widget can be viewed, and perhaps it's thrown by a widget that is styled `overlay: screen` (which `Select` is).

More digging to do, but what I'm expecting to find is that, perhaps, it always says `False`.
Close, I think. It's not true that `Widget.can_view` is `False` for the `Select`, but it *is* for the `SelectOverlay`, even when it is visible.
I'm not sure if this is *the* fix yet, but I've now narrowed it down to this:

```diff
diff --git a/src/textual/widget.py b/src/textual/widget.py
index 46ef4d781..4ea199695 100644
--- a/src/textual/widget.py
+++ b/src/textual/widget.py
@@ -2557,6 +2557,9 @@ class Widget(DOMNode):
         if widget is self:
             return True
 
+        if widget.styles.overlay == "screen":
+            return True
+
         region = widget.region
         node: Widget = widget
 
```

with this change in place the above code works as expected.
Just to narrow things in a wee bit more: the issue is within `Widget.can_view`, and in particular this code:

```python
if region not in node.parent.scrollable_content_region:
```

The problem is this, as best as I can tell: the `Select` is a `Vertical` that has `height: auto`. The `Select` exists in "normal screen space" (for want of a better term). So because it encapsulates the `SelectCurrent` its region is something like this (from my test setup): `Region(×=34, y=32, width=174, height=3)`.

Note especially the height.

On the other hand the drop-down portion (the `SelectOverlay`) has a region like this: `Region(×=34, y=35, width=174, height=12)`. So while the `Select` is the parent of the `SelectOverlay`, it can never be inside the `Select`, because `Select > SelectOverlay` is specified to be `overlay: screen` and will always be visually outside of its parent while also being (from a DOM standpoint) within its parent.

Some special-case handling may be required here; either on `Select` itself (which doesn't seem great), or on widgets that are `overlay: screen`.

Resolving patch:
diff --git a/src/textual/screen.py b/src/textual/screen.py
--- a/src/textual/screen.py
+++ b/src/textual/screen.py
@@ -939,6 +939,25 @@ def action_dismiss(
         """
         self.dismiss(result)
 
+    def can_view(self, widget: Widget) -> bool:
+        """Check if a given widget is in the current view (scrollable area).
+
+        Note: This doesn't necessarily equate to a widget being visible.
+        There are other reasons why a widget may not be visible.
+
+        Args:
+            widget: A widget that is a descendant of self.
+
+        Returns:
+            True if the entire widget is in view, False if it is partially visible or not in view.
+        """
+        # If the widget is one that overlays the screen...
+        if widget.styles.overlay == "screen":
+            # ...simply check if it's within the screen's region.
+            return widget.region in self.region
+        # Failing that fall back to normal checking.
+        return super().can_view(widget)
+
 
 @rich.repr.auto
 class ModalScreen(Screen[ScreenResultType]):


Test patch: