z=X behavior is surprising with nested views/layouts
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
Brought this over to an issue incase Discussion visibility is low - feel free to close if there is no interest.
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.
https://github.com/Textualize/textual/wiki/Sorry-we-closed-your-issue
Did we solve your problem?
Glad we could help!