Skip to content

Commit

Permalink
Merge pull request #4931 from Textualize/maximize
Browse files Browse the repository at this point in the history
Feature to maximize widgets
  • Loading branch information
willmcgugan authored Aug 26, 2024
2 parents db3fda9 + 82d61c1 commit fba4773
Show file tree
Hide file tree
Showing 12 changed files with 599 additions and 81 deletions.
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":
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

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"
"""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, maximize that instead.
"""
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

0 comments on commit fba4773

Please sign in to comment.