Skip to content

Commit fba4773

Browse files
authored
Merge pull request #4931 from Textualize/maximize
Feature to maximize widgets
2 parents db3fda9 + 82d61c1 commit fba4773

File tree

12 files changed

+599
-81
lines changed

12 files changed

+599
-81
lines changed

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](http://keepachangelog.com/)
66
and this project adheres to [Semantic Versioning](http://semver.org/).
77

8+
9+
## Unreleased
10+
11+
### Added
12+
13+
- Added Maximize and Minimize system commands. https://github.com/Textualize/textual/pull/4931
14+
- Added `Screen.maximize`, `Screen.minimize`, `Screen.action_maximize`, `Screen.action_minimize`, `Widget.is_maximized`, `Widget.allow_maximize`. https://github.com/Textualize/textual/pull/4931
15+
- Added `Widget.ALLOW_MAXIMIZE`, `Screen.ALLOW_IN_MAXIMIZED_VIEW` classvars https://github.com/Textualize/textual/pull/4931
16+
817
## [0.77.0] - 2024-08-22
918

1019
### Added

src/textual/app.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,15 @@ class App(Generic[ReturnType], DOMNode):
306306
App {
307307
background: $background;
308308
color: $text;
309+
Screen.-maximized-view {
310+
layout: vertical !important;
311+
hatch: right $panel;
312+
overflow-y: auto !important;
313+
align: center middle;
314+
.-maximized {
315+
dock: initial !important;
316+
}
317+
}
309318
}
310319
*:disabled:can-focus {
311320
opacity: 0.7;
@@ -992,6 +1001,17 @@ def get_system_commands(self, screen: Screen) -> Iterable[SystemCommand]:
9921001
self.action_show_help_panel,
9931002
)
9941003

1004+
if screen.maximized is not None:
1005+
yield SystemCommand(
1006+
"Minimize",
1007+
"Minimize the widget and restore to normal size",
1008+
screen.action_minimize,
1009+
)
1010+
elif screen.focused is not None and screen.focused.allow_maximize:
1011+
yield SystemCommand(
1012+
"Maximize", "Maximize the focused widget", screen.action_maximize
1013+
)
1014+
9951015
# Don't save screenshot for web drivers until we have the deliver_file in place
9961016
if self._driver.__class__.__name__ in {"LinuxDriver", "WindowsDriver"}:
9971017

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

34433463
async def _on_key(self, event: events.Key) -> None:
3464+
# Special case for maximized widgets
3465+
# If something is maximized, then escape should minimize
3466+
if self.screen.maximized is not None and event.key == "escape":
3467+
self.screen.minimize()
3468+
return
34443469
if not (await self._check_bindings(event.key)):
34453470
await dispatch_key(self, event)
34463471

src/textual/containers.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ class Container(Widget):
2929
class ScrollableContainer(Widget, can_focus=True, inherit_bindings=False):
3030
"""A scrollable container with vertical layout, and auto scrollbars on both axis."""
3131

32+
# We don't typically want to maximize scrollable containers,
33+
# since the user can easily navigate the contents
34+
ALLOW_MAXIMIZE = False
35+
3236
DEFAULT_CSS = """
3337
ScrollableContainer {
3438
width: 1fr;

src/textual/demo.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@
162162
}
163163
}
164164
}
165-
}
165+
}
166166
"""
167167

168168

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

191191

192192
class Welcome(Container):
193+
ALLOW_MAXIMIZE = True
194+
193195
def compose(self) -> ComposeResult:
194196
yield Static(Markdown(WELCOME_MD))
195197
yield Button("Start", variant="success")
@@ -256,6 +258,8 @@ def on_click(self) -> None:
256258

257259

258260
class LoginForm(Container):
261+
ALLOW_MAXIMIZE = True
262+
259263
def compose(self) -> ComposeResult:
260264
yield Static("Username", classes="label")
261265
yield Input(placeholder="Username")

src/textual/demo.tcss

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ Screen {
88
&:inline {
99
height: 50vh;
1010
}
11+
&.-maximized-view {
12+
overflow: auto;
13+
}
1114
}
1215

1316

src/textual/screen.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,11 @@
3030
from rich.style import Style
3131

3232
from . import constants, errors, events, messages
33+
from ._arrange import arrange
3334
from ._callback import invoke
3435
from ._compositor import Compositor, MapGeometry
3536
from ._context import active_message_pump, visible_screen_stack
37+
from ._layout import DockArrangeResult
3638
from ._path import CSSPathType, _css_path_type_as_list, _make_path_object_relative
3739
from ._types import CallbackType
3840
from .await_complete import AwaitComplete
@@ -184,6 +186,11 @@ class Screen(Generic[ScreenResultType], Widget):
184186
185187
Should be a set of [`command.Provider`][textual.command.Provider] classes.
186188
"""
189+
ALLOW_IN_MAXIMIZED_VIEW: ClassVar[str] = ".-textual-system,Footer"
190+
"""A selector for the widgets (direct children of Screen) that are allowed in the maximized view (in addition to maximized widget)."""
191+
192+
maximized: Reactive[Widget | None] = Reactive(None, layout=True)
193+
"""The currently maximized widget, or `None` for no maximized widget."""
187194

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

297+
def _watch_maximized(
298+
self, previously_maximized: Widget | None, maximized: Widget | None
299+
) -> None:
300+
# The screen gets a `-maximized-view` class if there is a maximized widget
301+
# The widget gets a `-maximized` class if it is maximized
302+
self.set_class(maximized is not None, "-maximized-view")
303+
if previously_maximized is not None:
304+
previously_maximized.remove_class("-maximized")
305+
if maximized is not None:
306+
maximized.add_class("-maximized")
307+
290308
@property
291309
def _binding_chain(self) -> list[tuple[DOMNode, BindingsMap]]:
292310
"""Binding chain from this screen."""
@@ -358,6 +376,34 @@ def active_bindings(self) -> dict[str, ActiveBinding]:
358376

359377
return bindings_map
360378

379+
def _arrange(self, size: Size) -> DockArrangeResult:
380+
"""Arrange children.
381+
382+
Args:
383+
size: Size of container.
384+
385+
Returns:
386+
Widget locations.
387+
"""
388+
# This is customized over the base class to allow for a widget to be maximized
389+
cache_key = (size, self._nodes._updates, self.maximized)
390+
cached_result = self._arrangement_cache.get(cache_key)
391+
if cached_result is not None:
392+
return cached_result
393+
394+
arrangement = self._arrangement_cache[cache_key] = arrange(
395+
self,
396+
(
397+
[self.maximized, *self.query_children(self.ALLOW_IN_MAXIMIZED_VIEW)]
398+
if self.maximized is not None
399+
else self._nodes
400+
),
401+
size,
402+
self.screen.size,
403+
)
404+
405+
return arrangement
406+
361407
@property
362408
def is_active(self) -> bool:
363409
"""Is the screen active (i.e. visible and top of the stack)?"""
@@ -542,12 +588,19 @@ def _move_focus(
542588
is not `None`, then it is guaranteed that the widget returned matches
543589
the CSS selectors given in the argument.
544590
"""
591+
545592
# TODO: This shouldn't be required
546593
self._compositor._full_map_invalidated = True
547594
if not isinstance(selector, str):
548595
selector = selector.__name__
549596
selector_set = parse_selectors(selector)
550597
focus_chain = self.focus_chain
598+
599+
# If a widget is maximized we want to limit the focus chain to the visible widgets
600+
if self.maximized is not None:
601+
focusable = set(self.maximized.walk_children(with_self=True))
602+
focus_chain = [widget for widget in focus_chain if widget in focusable]
603+
551604
filtered_focus_chain = (
552605
node for node in focus_chain if match(selector_set, node)
553606
)
@@ -621,6 +674,42 @@ def focus_previous(self, selector: str | type[QueryType] = "*") -> Widget | None
621674
"""
622675
return self._move_focus(-1, selector)
623676

677+
def maximize(self, widget: Widget, container: bool = True) -> None:
678+
"""Maximize a widget, so it fills the screen.
679+
680+
Args:
681+
widget: Widget to maximize.
682+
container: If one of the widgets ancestors is a maximizeable widget, maximize that instead.
683+
"""
684+
if widget.allow_maximize:
685+
if container:
686+
# If we want to maximize the container, look up the dom to find a suitable widget
687+
for maximize_widget in widget.ancestors:
688+
if not isinstance(maximize_widget, Widget):
689+
break
690+
if maximize_widget.allow_maximize:
691+
self.maximized = maximize_widget
692+
return
693+
694+
self.maximized = widget
695+
696+
def minimize(self) -> None:
697+
"""Restore any maximized widget to normal state."""
698+
self.maximized = None
699+
if self.focused is not None:
700+
self.call_after_refresh(
701+
self.scroll_to_widget, self.focused, animate=False, center=True
702+
)
703+
704+
def action_maximize(self) -> None:
705+
"""Action to maximize the currently focused widget."""
706+
if self.focused is not None:
707+
self.maximize(self.focused)
708+
709+
def action_minimize(self) -> None:
710+
"""Action to minimize the currently maximized widget."""
711+
self.minimize()
712+
624713
def _reset_focus(
625714
self, widget: Widget, avoiding: list[Widget] | None = None
626715
) -> None:

src/textual/scroll_view.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ class ScrollView(ScrollableContainer):
1818
on the compositor to render children).
1919
"""
2020

21+
ALLOW_MAXIMIZE = True
22+
2123
DEFAULT_CSS = """
2224
ScrollView {
2325
overflow-y: auto;

src/textual/widget.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,15 @@ class Widget(DOMNode):
304304
BORDER_SUBTITLE: ClassVar[str] = ""
305305
"""Initial value for border_subtitle attribute."""
306306

307+
ALLOW_MAXIMIZE: ClassVar[bool | None] = None
308+
"""Defines default logic to allow the widget to be maximized.
309+
310+
- `None` Use default behavior (Focusable widgets may be maximized)
311+
- `False` Do not allow widget to be maximized
312+
- `True` Allow widget to be maximized
313+
314+
"""
315+
307316
can_focus: bool = False
308317
"""Widget may receive focus."""
309318
can_focus_children: bool = True
@@ -514,6 +523,15 @@ def _allow_scroll(self) -> bool:
514523
self.allow_horizontal_scroll or self.allow_vertical_scroll
515524
)
516525

526+
@property
527+
def allow_maximize(self) -> bool:
528+
"""Check if the widget may be maximized.
529+
530+
Returns:
531+
`True` if the widget may be maximized, or `False` if it should not be maximized.
532+
"""
533+
return self.can_focus if self.ALLOW_MAXIMIZE is None else self.ALLOW_MAXIMIZE
534+
517535
@property
518536
def offset(self) -> Offset:
519537
"""Widget offset from origin.
@@ -558,6 +576,14 @@ def is_mouse_over(self) -> bool:
558576
return True
559577
return False
560578

579+
@property
580+
def is_maximized(self) -> bool:
581+
"""Is this widget maximized?"""
582+
try:
583+
return self.screen.maximized is self
584+
except NoScreen:
585+
return False
586+
561587
def anchor(self, *, animate: bool = False) -> None:
562588
"""Anchor the widget, which scrolls it into view (like [scroll_visible][textual.widget.Widget.scroll_visible]),
563589
but also keeps it in view if the widget's size changes, or the size of its container changes.
@@ -3045,7 +3071,7 @@ def __init_subclass__(
30453071
name = cls.__name__
30463072
if not name[0].isupper() and not name.startswith("_"):
30473073
raise BadWidgetName(
3048-
f"Widget subclass {name!r} should be capitalised or start with '_'."
3074+
f"Widget subclass {name!r} should be capitalized or start with '_'."
30493075
)
30503076

30513077
super().__init_subclass__(

0 commit comments

Comments
 (0)