Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature to maximize widgets #4931

Merged
merged 15 commits into from
Aug 26, 2024
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).


## Unreleased

### Added

- Added Maximize and Minimize system commands. https://github.com/Textualize/textual/pull/4931
- Added `Screen.maximize`, `Screen.minimize`, `Screen.action_maximize`, `Screen.action_minimize`, `Widget.is_maximized`, `Widget.allow_maximize`. https://github.com/Textualize/textual/pull/4931
- Added `Widget.ALLOW_MAXIMIZE`, `Screen.ALLOW_IN_MAXIMIZED_VIEW` classvars https://github.com/Textualize/textual/pull/4931

## [0.77.0] - 2024-08-22

### Added
Expand Down
25 changes: 25 additions & 0 deletions src/textual/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,15 @@ class App(Generic[ReturnType], DOMNode):
App {
background: $background;
color: $text;
Screen.-maximized-view {
layout: vertical !important;
hatch: right $panel;
overflow-y: auto !important;
align: center middle;
.-maximized {
dock: initial !important;
}
}
}
*:disabled:can-focus {
opacity: 0.7;
Expand Down Expand Up @@ -992,6 +1001,17 @@ def get_system_commands(self, screen: Screen) -> Iterable[SystemCommand]:
self.action_show_help_panel,
)

if screen.maximized is not None:
yield SystemCommand(
"Minimize",
"Minimize the widget and restore to normal size",
screen.action_minimize,
)
elif screen.focused is not None and screen.focused.allow_maximize:
yield SystemCommand(
"Maximize", "Maximize the focused widget", screen.action_maximize
)

# Don't save screenshot for web drivers until we have the deliver_file in place
if self._driver.__class__.__name__ in {"LinuxDriver", "WindowsDriver"}:

Expand Down Expand Up @@ -3441,6 +3461,11 @@ async def _on_layout(self, message: messages.Layout) -> None:
message.stop()

async def _on_key(self, event: events.Key) -> None:
# Special case for maximized widgets
# If something is maximized, then escape should minimize
if self.screen.maximized is not None and event.key == "escape":
darrenburns marked this conversation as resolved.
Show resolved Hide resolved
self.screen.minimize()
return
if not (await self._check_bindings(event.key)):
await dispatch_key(self, event)

Expand Down
4 changes: 4 additions & 0 deletions src/textual/containers.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ class Container(Widget):
class ScrollableContainer(Widget, can_focus=True, inherit_bindings=False):
"""A scrollable container with vertical layout, and auto scrollbars on both axis."""

# We don't typically want to maximize scrollable containers,
# since the user can easily navigate the contents
ALLOW_MAXIMIZE = False
darrenburns marked this conversation as resolved.
Show resolved Hide resolved

DEFAULT_CSS = """
ScrollableContainer {
width: 1fr;
Expand Down
6 changes: 5 additions & 1 deletion src/textual/demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@
}
}
}
}
}
"""


Expand Down Expand Up @@ -190,6 +190,8 @@ def on_switch_changed(self, event: Switch.Changed) -> None:


class Welcome(Container):
ALLOW_MAXIMIZE = True

def compose(self) -> ComposeResult:
yield Static(Markdown(WELCOME_MD))
yield Button("Start", variant="success")
Expand Down Expand Up @@ -256,6 +258,8 @@ def on_click(self) -> None:


class LoginForm(Container):
ALLOW_MAXIMIZE = True

def compose(self) -> ComposeResult:
yield Static("Username", classes="label")
yield Input(placeholder="Username")
Expand Down
3 changes: 3 additions & 0 deletions src/textual/demo.tcss
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ Screen {
&:inline {
height: 50vh;
}
&.-maximized-view {
overflow: auto;
}
}


Expand Down
89 changes: 89 additions & 0 deletions src/textual/screen.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,11 @@
from rich.style import Style

from . import constants, errors, events, messages
from ._arrange import arrange
from ._callback import invoke
from ._compositor import Compositor, MapGeometry
from ._context import active_message_pump, visible_screen_stack
from ._layout import DockArrangeResult
from ._path import CSSPathType, _css_path_type_as_list, _make_path_object_relative
from ._types import CallbackType
from .await_complete import AwaitComplete
Expand Down Expand Up @@ -184,6 +186,11 @@ class Screen(Generic[ScreenResultType], Widget):

Should be a set of [`command.Provider`][textual.command.Provider] classes.
"""
ALLOW_IN_MAXIMIZED_VIEW: ClassVar[str] = ".-textual-system,Footer"
darrenburns marked this conversation as resolved.
Show resolved Hide resolved
darrenburns marked this conversation as resolved.
Show resolved Hide resolved
"""A selector for the widgets (direct children of Screen) that are allowed in the maximized view (in addition to maximized widget)."""

maximized: Reactive[Widget | None] = Reactive(None, layout=True)
"""The currently maximized widget, or `None` for no maximized widget."""

BINDINGS = [
Binding("tab", "app.focus_next", "Focus Next", show=False),
Expand Down Expand Up @@ -287,6 +294,17 @@ def refresh_bindings(self) -> None:
self._bindings_updated = True
self.check_idle()

def _watch_maximized(
self, previously_maximized: Widget | None, maximized: Widget | None
) -> None:
# The screen gets a `-maximized-view` class if there is a maximized widget
# The widget gets a `-maximized` class if it is maximized
self.set_class(maximized is not None, "-maximized-view")
if previously_maximized is not None:
previously_maximized.remove_class("-maximized")
if maximized is not None:
maximized.add_class("-maximized")

@property
def _binding_chain(self) -> list[tuple[DOMNode, BindingsMap]]:
"""Binding chain from this screen."""
Expand Down Expand Up @@ -358,6 +376,34 @@ def active_bindings(self) -> dict[str, ActiveBinding]:

return bindings_map

def _arrange(self, size: Size) -> DockArrangeResult:
"""Arrange children.

Args:
size: Size of container.

Returns:
Widget locations.
"""
# This is customized over the base class to allow for a widget to be maximized
cache_key = (size, self._nodes._updates, self.maximized)
cached_result = self._arrangement_cache.get(cache_key)
if cached_result is not None:
return cached_result

arrangement = self._arrangement_cache[cache_key] = arrange(
self,
(
[self.maximized, *self.query_children(self.ALLOW_IN_MAXIMIZED_VIEW)]
if self.maximized is not None
else self._nodes
),
size,
self.screen.size,
)

return arrangement

@property
def is_active(self) -> bool:
"""Is the screen active (i.e. visible and top of the stack)?"""
Expand Down Expand Up @@ -542,12 +588,19 @@ def _move_focus(
is not `None`, then it is guaranteed that the widget returned matches
the CSS selectors given in the argument.
"""

# TODO: This shouldn't be required
self._compositor._full_map_invalidated = True
if not isinstance(selector, str):
selector = selector.__name__
selector_set = parse_selectors(selector)
focus_chain = self.focus_chain

# If a widget is maximized we want to limit the focus chain to the visible widgets
if self.maximized is not None:
focusable = set(self.maximized.walk_children(with_self=True))
focus_chain = [widget for widget in focus_chain if widget in focusable]

filtered_focus_chain = (
node for node in focus_chain if match(selector_set, node)
)
Expand Down Expand Up @@ -621,6 +674,42 @@ def focus_previous(self, selector: str | type[QueryType] = "*") -> Widget | None
"""
return self._move_focus(-1, selector)

def maximize(self, widget: Widget, container: bool = True) -> None:
"""Maximize a widget, so it fills the screen.

Args:
widget: Widget to maximize.
container: If one of the widgets ancestors is a maximizeable widget, also maximize that.
willmcgugan marked this conversation as resolved.
Show resolved Hide resolved
"""
if widget.allow_maximize:
if container:
# If we want to maximize the container, look up the dom to find a suitable widget
for maximize_widget in widget.ancestors:
if not isinstance(maximize_widget, Widget):
break
if maximize_widget.allow_maximize:
self.maximized = maximize_widget
return

self.maximized = widget

def minimize(self) -> None:
"""Restore any maximized widget to normal state."""
self.maximized = None
if self.focused is not None:
self.call_after_refresh(
self.scroll_to_widget, self.focused, animate=False, center=True
)

def action_maximize(self) -> None:
"""Action to maximize the currently focused widget."""
if self.focused is not None:
self.maximize(self.focused)

def action_minimize(self) -> None:
"""Action to minimize the currently maximized widget."""
self.minimize()

def _reset_focus(
self, widget: Widget, avoiding: list[Widget] | None = None
) -> None:
Expand Down
2 changes: 2 additions & 0 deletions src/textual/scroll_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ class ScrollView(ScrollableContainer):
on the compositor to render children).
"""

ALLOW_MAXIMIZE = True

DEFAULT_CSS = """
ScrollView {
overflow-y: auto;
Expand Down
28 changes: 27 additions & 1 deletion src/textual/widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,15 @@ class Widget(DOMNode):
BORDER_SUBTITLE: ClassVar[str] = ""
"""Initial value for border_subtitle attribute."""

ALLOW_MAXIMIZE: ClassVar[bool | None] = None
"""Defines default logic to allow the widget to be maximized.

- `None` Use default behavior (Focusable widgets may be maximized)
- `False` Do not allow widget to be maximized
- `True` Allow widget to be maximized

"""

can_focus: bool = False
"""Widget may receive focus."""
can_focus_children: bool = True
Expand Down Expand Up @@ -514,6 +523,15 @@ def _allow_scroll(self) -> bool:
self.allow_horizontal_scroll or self.allow_vertical_scroll
)

@property
def allow_maximize(self) -> bool:
"""Check if the widget may be maximized.

Returns:
`True` if the widget may be maximized, or `False` if it should not be maximized.
"""
return self.can_focus if self.ALLOW_MAXIMIZE is None else self.ALLOW_MAXIMIZE

@property
def offset(self) -> Offset:
"""Widget offset from origin.
Expand Down Expand Up @@ -558,6 +576,14 @@ def is_mouse_over(self) -> bool:
return True
return False

@property
def is_maximized(self) -> bool:
"""Is this widget maximized?"""
try:
return self.screen.maximized is self
except NoScreen:
return False

def anchor(self, *, animate: bool = False) -> None:
"""Anchor the widget, which scrolls it into view (like [scroll_visible][textual.widget.Widget.scroll_visible]),
but also keeps it in view if the widget's size changes, or the size of its container changes.
Expand Down Expand Up @@ -3045,7 +3071,7 @@ def __init_subclass__(
name = cls.__name__
if not name[0].isupper() and not name.startswith("_"):
raise BadWidgetName(
f"Widget subclass {name!r} should be capitalised or start with '_'."
f"Widget subclass {name!r} should be capitalized or start with '_'."
)

super().__init_subclass__(
Expand Down
Loading
Loading