textual icon indicating copy to clipboard operation
textual copied to clipboard

z=X behavior is surprising with nested views/layouts

Open zparmley opened this issue 3 years ago • 2 comments

Discussed in https://github.com/Textualize/textual/discussions/314

Originally posted by zparmley February 28, 2022 When setting z=X on a layout that is nested, the behavior is surprising. Controlling the eventual rendered z of a nested layout ranges from difficult to impossible (i.e. with a scrollview)

Examples of unexpected behavior:

from rich.text import Text
from textual import events
from textual.app import App
from textual.views import DockView
from textual.widget import Widget
from textual.widgets import Placeholder
from textual.widgets import ScrollView


class LongText(Widget):
    def __init__(self):
        super().__init__(name='long_text')
        self.border = 'round'
        self.layout_offset_y = 20

    def render(self):
        text = Text.assemble(*(f'{i}\n' for i in range(40)))
        return text


class Z_DockADockView(App):
    """Docking a DockView with z=1 behaves surprisingly - renders behind main layout"""
    shown = False

    async def action_toggle_scroll(self) -> None:
        offset = self.shown and -20 or 0
        self.shown = not self.shown
        self.long_text.animate('layout_offset_y', offset)

    async def on_load(self, event: events.Load) -> None:
        """Bind keys with the app loads (but before entering application mode)"""
        self.shown = False
        await self.bind('b', 'toggle_scroll', 'Toggle scroll')
        await self.bind('q', 'quit', 'Quit')

    async def on_mount(self, event: events.Mount) -> None:
        """Create and dock the widgets."""
        self.long_text = LongText()
        self.subview = DockView()
        await self.subview.dock(self.long_text, edge='top')
        """ Above would would work as expected if z=1 set there
            However, less surprising would be if the z=1 on the following line affected it
        """
        await self.view.dock(self.subview, edge='top', size=10, z=1)
        await self.view.dock(Placeholder(), edge='top', size=8, name='main')


class Z_DockADockViewAlt(App):
    """Docking a DockView with a z=1 widget docked, behaves surprisingly - z as expected but outer layout takes up spaces asif z=0"""
    shown = False

    async def action_toggle_scroll(self) -> None:
        offset = self.shown and -20 or 0
        self.shown = not self.shown
        self.long_text.animate('layout_offset_y', offset)

    async def on_load(self, event: events.Load) -> None:
        """Bind keys with the app loads (but before entering application mode)"""
        self.shown = False
        await self.bind('b', 'toggle_scroll', 'Toggle scroll')
        await self.bind('q', 'quit', 'Quit')

    async def on_mount(self, event: events.Mount) -> None:
        """Create and dock the widgets."""
        self.long_text = LongText()
        self.subview = DockView()
        await self.subview.dock(self.long_text, edge='top', z=1)
        await self.view.dock(self.subview, edge='top', size=10)
        await self.view.dock(Placeholder(), edge='top', size=8, name='main')


class Z_ScrollView(App):
    """Setting Z when docking a ScrollView gets unmanagable - renders behind main placeholder, an no way currently to pass z-index into scrollview"""

    async def action_toggle_scroll(self) -> None:
        """ Toggle the long_text offset - show or hide"""
        offset = self.shown and 20 or 0
        self.shown = not self.shown
        # self.scroll.animate('layout_offset_y', offset)  # I'm also inclined to think this should work... however, the folling is required instead
        self.long_text.animate('layout_offset_y', offset)

    async def on_load(self, event: events.Load) -> None:
        """Bind keys with the app loads (but before entering application mode)"""
        self.shown = False
        await self.bind('b', 'toggle_scroll', 'Toggle scroll')
        await self.bind('q', 'quit', 'Quit')

    async def on_mount(self, event: events.Mount) -> None:
        """Create and dock the widgets.

        Note that the scrollview, confusingly, show's _behind_ the main placeholder, not in front of it
        """
        self.subview = DockView()
        await self.subview.dock(Placeholder(), edge='top', name='main')
        self.long_text = LongText()
        self.scroll = ScrollView(self.long_text)
        await self.subview.dock(self.scroll, edge='bottom', size=10, z=1)
        await self.view.dock(self.subview, edge='top')


Z_DockADockView.run(title='Z_DockADockView example')
Z_DockADockViewAlt.run(title='Z_DockADockView Alt example')
Z_ScrollView.run(title='Z_ScrollView example')

I propose as a solution:

  • Adding a z_index contextvar to _context which initializes to 0.
  • Adding a z_index_context (name?) contextmanager to layout.py which sets the contextvar and returns it to previous value on exit
  • Letting all places where z=X can be passed (this seems to mainly be the domain of Layouts) - default to None instead of 0
    • When z=None, use value of the z_index contextvar

I wouldn't call this solution perfect - I think some thinking needs to go into z-index of a layout being maybe relative to the layout it's nested inside of? But I think this does provide a simple/intuitive interface that allows setting z-index in most/all situations with the current setup.

With the proposed solution:

from rich.text import Text
from textual import events
from textual.app import App
from textual.views import DockView
from textual.layout import z_index_context
from textual.widget import Widget
from textual.widgets import Placeholder
from textual.widgets import ScrollView


class LongText(Widget):
    def __init__(self):
        super().__init__(name='long_text')
        self.border = 'round'
        self.layout_offset_y = 20

    def render(self):
        text = Text.assemble(*(f'{i}\n' for i in range(40)))
        return text


class Proposed_Z_ScrollView(App):
    """Setting Z context when docking a ScrollView works as expected"""

    async def action_toggle_scroll(self) -> None:
        """ Toggle the long_text offset - show or hide"""
        offset = self.shown and 20 or 0
        self.shown = not self.shown
        # self.scroll.animate('layout_offset_y', offset)  # I'm also inclined to think this should work... however, the folling is required instead
        self.long_text.animate('layout_offset_y', offset)
        self.scroll.animate('layout_offset_y', offset)
        self.subview.animate('layout_offset_y', offset)

    async def on_load(self, event: events.Load) -> None:
        """Bind keys with the app loads (but before entering application mode)"""
        self.shown = False
        await self.bind('b', 'toggle_scroll', 'Toggle scroll')
        await self.bind('q', 'quit', 'Quit')

    async def on_mount(self, event: events.Mount) -> None:
        """Create and dock the widgets.

        Note that the scrollview, confusingly, show's _behind_ the main placeholder, not in front of it
        """
        self.subview = DockView()
        await self.subview.dock(Placeholder(), edge='top', name='main')
        with z_index_context(1):
            self.long_text = LongText()
            self.scroll = ScrollView(self.long_text)
            await self.subview.dock(self.scroll, edge='bottom', size=10)
            # await self.view.dock(self.subview, edge='bottom', size=5)
        await self.view.dock(self.subview, edge='top')


class Proposed_Z_DockADockView(App):
    """Docking a DockView also fails to respect the z set on the top-level docking"""
    shown = False

    async def action_toggle_scroll(self) -> None:
        offset = self.shown and -20 or 0
        self.shown = not self.shown
        self.long_text.animate('layout_offset_y', offset)

    async def on_load(self, event: events.Load) -> None:
        """Bind keys with the app loads (but before entering application mode)"""
        self.shown = False
        await self.bind('b', 'toggle_scroll', 'Toggle scroll')
        await self.bind('q', 'quit', 'Quit')

    async def on_mount(self, event: events.Mount) -> None:
        """Create and dock the widgets."""
        self.long_text = LongText()
        self.subview = DockView()
        with z_index_context(1):
            await self.subview.dock(self.long_text, edge='top')
            await self.view.dock(self.subview, edge='top', size=10)
        await self.view.dock(Placeholder(), edge='top', size=8, name='main')


Proposed_Z_ScrollView.run(title='Z_ScrollView example')

Proposed_Z_DockADockView.run(title='Z_DockADockView example')

I've got a working implementation - if you like the proposed solution I'm happy to show or open a PR

zparmley avatar Mar 07 '22 02:03 zparmley

Brought this over to an issue incase Discussion visibility is low - feel free to close if there is no interest.

zparmley avatar Mar 07 '22 02:03 zparmley

I'm interested in your solution. I experienced the same problem with a so-called 'Floating ScrollView' which most of the time just doesn't show or shows behind the main view.

rhuygen avatar May 18 '22 13:05 rhuygen

https://github.com/Textualize/textual/wiki/Sorry-we-closed-your-issue

willmcgugan avatar Oct 25 '22 09:10 willmcgugan

Did we solve your problem?

Glad we could help!

github-actions[bot] avatar Oct 25 '22 09:10 github-actions[bot]