381 lines
9.7 KiB
Python
381 lines
9.7 KiB
Python
"""Responsive layout system for TUI applications."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
from textual.app import ComposeResult
|
|
from textual.containers import Container, VerticalScroll
|
|
from textual.widgets import Static
|
|
from typing_extensions import override
|
|
|
|
|
|
class ResponsiveGrid(Container):
|
|
"""Grid that auto-adjusts based on terminal size."""
|
|
|
|
DEFAULT_CSS = """
|
|
ResponsiveGrid {
|
|
layout: grid;
|
|
grid-size: 1;
|
|
grid-columns: 1fr;
|
|
grid-rows: auto;
|
|
grid-gutter: 1;
|
|
padding: 1;
|
|
}
|
|
|
|
ResponsiveGrid.two-column {
|
|
grid-size: 2;
|
|
grid-columns: 1fr 1fr;
|
|
}
|
|
|
|
ResponsiveGrid.three-column {
|
|
grid-size: 3;
|
|
grid-columns: 1fr 1fr 1fr;
|
|
}
|
|
|
|
ResponsiveGrid.auto-fit {
|
|
grid-columns: repeat(auto-fit, minmax(20, 1fr));
|
|
}
|
|
|
|
ResponsiveGrid.compact {
|
|
grid-gutter: 0;
|
|
padding: 0;
|
|
}
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
*children: Any,
|
|
columns: int = 1,
|
|
auto_fit: bool = False,
|
|
compact: bool = False,
|
|
**kwargs: Any,
|
|
) -> None:
|
|
"""Initialize responsive grid."""
|
|
super().__init__(*children, **kwargs)
|
|
self.columns = columns
|
|
self.auto_fit = auto_fit
|
|
self.compact = compact
|
|
|
|
def on_mount(self) -> None:
|
|
"""Apply responsive classes based on configuration."""
|
|
if self.auto_fit:
|
|
_ = self.add_class("auto-fit")
|
|
elif self.columns == 2:
|
|
_ = self.add_class("two-column")
|
|
elif self.columns == 3:
|
|
_ = self.add_class("three-column")
|
|
|
|
if self.compact:
|
|
_ = self.add_class("compact")
|
|
|
|
def on_resize(self) -> None:
|
|
"""Adjust layout based on terminal size."""
|
|
if self.auto_fit:
|
|
# Let CSS handle auto-fit
|
|
return
|
|
|
|
terminal_width = self.size.width
|
|
if terminal_width < 60:
|
|
# Force single column on narrow terminals
|
|
_ = self.remove_class("two-column", "three-column")
|
|
self.styles.grid_size_columns = 1
|
|
self.styles.grid_columns = "1fr"
|
|
elif terminal_width < 100 and self.columns > 2:
|
|
# Force two columns on medium terminals
|
|
_ = self.remove_class("three-column")
|
|
_ = self.add_class("two-column")
|
|
self.styles.grid_size_columns = 2
|
|
self.styles.grid_columns = "1fr 1fr"
|
|
elif self.columns == 2:
|
|
_ = self.add_class("two-column")
|
|
elif self.columns == 3:
|
|
_ = self.add_class("three-column")
|
|
|
|
|
|
class CollapsibleSidebar(Container):
|
|
"""Sidebar that can be collapsed to save space."""
|
|
|
|
DEFAULT_CSS = """
|
|
CollapsibleSidebar {
|
|
dock: left;
|
|
width: 25%;
|
|
min-width: 20;
|
|
max-width: 40;
|
|
background: $surface;
|
|
border-right: solid $border;
|
|
padding: 1;
|
|
transition: width 300ms;
|
|
}
|
|
|
|
CollapsibleSidebar.collapsed {
|
|
width: 3;
|
|
min-width: 3;
|
|
overflow: hidden;
|
|
}
|
|
|
|
CollapsibleSidebar.collapsed > * {
|
|
display: none;
|
|
}
|
|
|
|
CollapsibleSidebar .sidebar-toggle {
|
|
dock: top;
|
|
height: 1;
|
|
background: $primary;
|
|
color: $text;
|
|
text-align: center;
|
|
margin-bottom: 1;
|
|
}
|
|
|
|
CollapsibleSidebar .sidebar-content {
|
|
height: 1fr;
|
|
overflow-y: auto;
|
|
}
|
|
"""
|
|
|
|
def __init__(self, *children: Any, collapsed: bool = False, **kwargs: Any) -> None:
|
|
"""Initialize collapsible sidebar."""
|
|
super().__init__(**kwargs)
|
|
self.collapsed = collapsed
|
|
self._children = children
|
|
|
|
@override
|
|
def compose(self) -> ComposeResult:
|
|
"""Compose sidebar with toggle and content."""
|
|
yield Static("☰", classes="sidebar-toggle")
|
|
with VerticalScroll(classes="sidebar-content"):
|
|
yield from self._children
|
|
|
|
def on_mount(self) -> None:
|
|
"""Apply initial collapsed state."""
|
|
if self.collapsed:
|
|
_ = self.add_class("collapsed")
|
|
|
|
def on_click(self) -> None:
|
|
"""Toggle sidebar when clicked."""
|
|
self.toggle()
|
|
|
|
def toggle(self) -> None:
|
|
"""Toggle sidebar collapsed state."""
|
|
self.collapsed = not self.collapsed
|
|
if self.collapsed:
|
|
_ = self.add_class("collapsed")
|
|
else:
|
|
_ = self.remove_class("collapsed")
|
|
|
|
def expand_sidebar(self) -> None:
|
|
"""Expand sidebar."""
|
|
if self.collapsed:
|
|
self.toggle()
|
|
|
|
def collapse_sidebar(self) -> None:
|
|
"""Collapse sidebar."""
|
|
if not self.collapsed:
|
|
self.toggle()
|
|
|
|
|
|
class TabularLayout(Container):
|
|
"""Optimized layout for data tables with optional sidebar."""
|
|
|
|
DEFAULT_CSS = """
|
|
TabularLayout {
|
|
layout: horizontal;
|
|
height: 100%;
|
|
}
|
|
|
|
TabularLayout .main-content {
|
|
width: 1fr;
|
|
height: 100%;
|
|
layout: vertical;
|
|
}
|
|
|
|
TabularLayout .table-container {
|
|
height: 1fr;
|
|
overflow: auto;
|
|
border: solid $border;
|
|
background: $surface;
|
|
}
|
|
|
|
TabularLayout .table-header {
|
|
dock: top;
|
|
height: 3;
|
|
background: $primary;
|
|
color: $text;
|
|
padding: 1;
|
|
}
|
|
|
|
TabularLayout .table-footer {
|
|
dock: bottom;
|
|
height: 3;
|
|
background: $surface-lighten-1;
|
|
padding: 1;
|
|
border-top: solid $border;
|
|
}
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
table_widget: Any,
|
|
header_content: Any | None = None,
|
|
footer_content: Any | None = None,
|
|
sidebar_content: Any | None = None,
|
|
**kwargs: Any,
|
|
) -> None:
|
|
"""Initialize tabular layout."""
|
|
super().__init__(**kwargs)
|
|
self.table_widget = table_widget
|
|
self.header_content = header_content
|
|
self.footer_content = footer_content
|
|
self.sidebar_content = sidebar_content
|
|
|
|
@override
|
|
def compose(self) -> ComposeResult:
|
|
"""Compose layout with optional sidebar."""
|
|
if self.sidebar_content:
|
|
yield CollapsibleSidebar(self.sidebar_content)
|
|
|
|
with Container(classes="main-content"):
|
|
if self.header_content:
|
|
yield Container(self.header_content, classes="table-header")
|
|
|
|
yield Container(self.table_widget, classes="table-container")
|
|
|
|
if self.footer_content:
|
|
yield Container(self.footer_content, classes="table-footer")
|
|
|
|
|
|
class CardLayout(ResponsiveGrid):
|
|
"""Grid layout optimized for card-based content."""
|
|
|
|
DEFAULT_CSS = """
|
|
CardLayout {
|
|
grid-gutter: 2;
|
|
padding: 2;
|
|
}
|
|
|
|
CardLayout .card {
|
|
background: $surface;
|
|
border: solid $border;
|
|
border-radius: 1;
|
|
padding: 2;
|
|
height: auto;
|
|
min-height: 10;
|
|
}
|
|
|
|
CardLayout .card:hover {
|
|
border: solid $accent;
|
|
background: $surface-lighten-1;
|
|
}
|
|
|
|
CardLayout .card:focus {
|
|
border: solid $primary;
|
|
}
|
|
|
|
CardLayout .card-header {
|
|
dock: top;
|
|
height: 3;
|
|
background: $primary-lighten-1;
|
|
color: $text;
|
|
padding: 1;
|
|
margin: -2 -2 1 -2;
|
|
border-radius: 1 1 0 0;
|
|
}
|
|
|
|
CardLayout .card-content {
|
|
height: 1fr;
|
|
overflow: auto;
|
|
}
|
|
|
|
CardLayout .card-footer {
|
|
dock: bottom;
|
|
height: 3;
|
|
background: $surface-darken-1;
|
|
padding: 1;
|
|
margin: 1 -2 -2 -2;
|
|
border-radius: 0 0 1 1;
|
|
}
|
|
"""
|
|
|
|
def __init__(self, **kwargs: Any) -> None:
|
|
"""Initialize card layout with default settings for cards."""
|
|
# Default to auto-fit cards with minimum width
|
|
super().__init__(auto_fit=True, **kwargs)
|
|
|
|
|
|
class SplitPane(Container):
|
|
"""Resizable split pane layout."""
|
|
|
|
DEFAULT_CSS = """
|
|
SplitPane {
|
|
layout: horizontal;
|
|
height: 100%;
|
|
}
|
|
|
|
SplitPane.vertical {
|
|
layout: vertical;
|
|
}
|
|
|
|
SplitPane .left-pane,
|
|
SplitPane .top-pane {
|
|
width: 50%;
|
|
height: 50%;
|
|
background: $surface;
|
|
border-right: solid $border;
|
|
border-bottom: solid $border;
|
|
}
|
|
|
|
SplitPane .right-pane,
|
|
SplitPane .bottom-pane {
|
|
width: 50%;
|
|
height: 50%;
|
|
background: $surface;
|
|
}
|
|
|
|
SplitPane .splitter {
|
|
width: 1;
|
|
height: 1;
|
|
background: $border;
|
|
}
|
|
|
|
SplitPane.vertical .splitter {
|
|
width: 100%;
|
|
height: 1;
|
|
}
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
left_content: Any,
|
|
right_content: Any,
|
|
vertical: bool = False,
|
|
split_ratio: float = 0.5,
|
|
**kwargs: Any,
|
|
) -> None:
|
|
"""Initialize split pane."""
|
|
super().__init__(**kwargs)
|
|
self.left_content = left_content
|
|
self.right_content = right_content
|
|
self.vertical = vertical
|
|
self.split_ratio = split_ratio
|
|
|
|
@override
|
|
def compose(self) -> ComposeResult:
|
|
"""Compose split pane layout."""
|
|
if self.vertical:
|
|
_ = self.add_class("vertical")
|
|
|
|
pane_classes = ("top-pane", "bottom-pane") if self.vertical else ("left-pane", "right-pane")
|
|
|
|
yield Container(self.left_content, classes=pane_classes[0])
|
|
yield Static("", classes="splitter")
|
|
yield Container(self.right_content, classes=pane_classes[1])
|
|
|
|
def on_mount(self) -> None:
|
|
"""Apply split ratio."""
|
|
if self.vertical:
|
|
self.query_one(f".{self.__class__.__name__} .top-pane").styles.height = f"{self.split_ratio * 100}%"
|
|
self.query_one(f".{self.__class__.__name__} .bottom-pane").styles.height = f"{(1 - self.split_ratio) * 100}%"
|
|
else:
|
|
self.query_one(f".{self.__class__.__name__} .left-pane").styles.width = f"{self.split_ratio * 100}%"
|
|
self.query_one(f".{self.__class__.__name__} .right-pane").styles.width = f"{(1 - self.split_ratio) * 100}%"
|