mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
Scrolling working
This commit is contained in:
@@ -28,6 +28,10 @@ class BasicApp(App):
|
||||
Placeholder(classes={"list-item"}),
|
||||
Placeholder(classes={"list-item"}),
|
||||
Placeholder(classes={"list-item"}),
|
||||
Placeholder(classes={"list-item"}),
|
||||
Placeholder(classes={"list-item"}),
|
||||
Placeholder(classes={"list-item"}),
|
||||
Placeholder(classes={"list-item"}),
|
||||
# Placeholder(id="child3", classes={"list-item"}),
|
||||
)
|
||||
uber1.show_vertical_scrollbar = True
|
||||
@@ -44,4 +48,4 @@ class BasicApp(App):
|
||||
self.panic(self.screen.tree)
|
||||
|
||||
|
||||
BasicApp.run(css_file="uber.css", log="textual.log")
|
||||
BasicApp.run(css_file="uber.css", log="textual.log", log_verbosity=0)
|
||||
|
||||
@@ -171,6 +171,11 @@ class Animator:
|
||||
easing (EasingFunction | str, optional): An easing function. Defaults to DEFAULT_EASING.
|
||||
"""
|
||||
|
||||
if not hasattr(obj, attribute):
|
||||
raise AttributeError(
|
||||
f"Can't animate attribute {attribute!r} on {obj!r}; attribute does not exist"
|
||||
)
|
||||
|
||||
if final_value is ...:
|
||||
final_value = value
|
||||
start_time = self.get_time()
|
||||
|
||||
22
src/textual/_arrange.py
Normal file
22
src/textual/_arrange.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Iterable
|
||||
|
||||
from .geometry import Size, Offset, Region
|
||||
from .layout import WidgetPlacement
|
||||
from .widget import Widget
|
||||
|
||||
|
||||
def arrange(
|
||||
widget: Widget, size: Size, scroll: Offset
|
||||
) -> tuple[list[WidgetPlacement], set[Widget]]:
|
||||
|
||||
assert widget.layout is not None
|
||||
|
||||
placements, widgets = widget.layout.arrange(
|
||||
widget,
|
||||
size - (widget.show_vertical_scrollbar, widget.show_horizontal_scrollbar),
|
||||
scroll,
|
||||
)
|
||||
|
||||
return placements, widgets
|
||||
@@ -416,10 +416,12 @@ class Compositor:
|
||||
Returns:
|
||||
SegmentLines: A renderable
|
||||
"""
|
||||
|
||||
width, height = self.size
|
||||
screen_region = Region(0, 0, width, height)
|
||||
|
||||
crop_region = crop.intersection(screen_region) if crop else screen_region
|
||||
# log("RENDER", crop=crop_region)
|
||||
|
||||
_Segment = Segment
|
||||
divide = _Segment.divide
|
||||
|
||||
@@ -287,10 +287,6 @@ class App(DOMNode):
|
||||
"""
|
||||
return self.screen.get_child(id)
|
||||
|
||||
def render_background(self) -> RenderableType:
|
||||
gradient = VerticalGradient("red", "blue")
|
||||
return gradient
|
||||
|
||||
def update_styles(self) -> None:
|
||||
"""Request update of styles.
|
||||
|
||||
@@ -408,7 +404,7 @@ class App(DOMNode):
|
||||
return
|
||||
|
||||
if self.css_monitor:
|
||||
self.set_interval(0.5, self.css_monitor)
|
||||
self.set_interval(0.5, self.css_monitor, name="css monitor")
|
||||
self.log("started", self.css_monitor)
|
||||
|
||||
self._running = True
|
||||
@@ -524,9 +520,11 @@ class App(DOMNode):
|
||||
try:
|
||||
if sync_available:
|
||||
console.file.write("\x1bP=1s\x1b\\")
|
||||
# renderable = self.screen._compositor.render(console)
|
||||
# console.print(renderable)
|
||||
console.print(
|
||||
ScreenRenderable(
|
||||
Control.home(), self.screen.render(), Control.home()
|
||||
Control.home(), self.screen._compositor, Control.home()
|
||||
),
|
||||
)
|
||||
if sync_available:
|
||||
|
||||
@@ -49,12 +49,18 @@ class Message:
|
||||
yield self.sender
|
||||
|
||||
def __init_subclass__(
|
||||
cls, bubble: bool = True, verbosity: int = 1, system: bool = False
|
||||
cls,
|
||||
bubble: bool | None = True,
|
||||
verbosity: int | None = 1,
|
||||
system: bool | None = False,
|
||||
) -> None:
|
||||
super().__init_subclass__()
|
||||
cls.bubble = bubble
|
||||
cls.verbosity = verbosity
|
||||
cls.system = system
|
||||
if bubble is not None:
|
||||
cls.bubble = bubble
|
||||
if verbosity is not None:
|
||||
cls.verbosity = verbosity
|
||||
if system is not None:
|
||||
cls.system = system
|
||||
|
||||
@property
|
||||
def is_forwarded(self) -> bool:
|
||||
|
||||
@@ -332,8 +332,8 @@ class MessagePump:
|
||||
|
||||
key_method = getattr(self, f"key_{event.key}", None)
|
||||
if key_method is not None:
|
||||
await invoke(key_method, event)
|
||||
event.prevent_default()
|
||||
if await invoke(key_method, event):
|
||||
event.prevent_default()
|
||||
|
||||
async def on_timer(self, event: events.Timer) -> None:
|
||||
event.prevent_default()
|
||||
|
||||
@@ -34,10 +34,6 @@ class Screen(Widget):
|
||||
|
||||
def render(self) -> RenderableType:
|
||||
return VerticalGradient("red", "blue")
|
||||
return self._compositor
|
||||
|
||||
def render_background(self) -> RenderableType:
|
||||
return VerticalGradient("#000000", "#00ff00")
|
||||
|
||||
def get_offset(self, widget: Widget) -> Offset:
|
||||
"""Get the absolute offset of a given Widget.
|
||||
@@ -199,6 +195,7 @@ class Screen(Widget):
|
||||
widget, _region = self.get_widget_at(event.x, event.y)
|
||||
except errors.NoWidget:
|
||||
return
|
||||
self.log("forward", widget, event)
|
||||
scroll_widget = widget
|
||||
if scroll_widget is not None:
|
||||
await scroll_widget.forward_event(event)
|
||||
|
||||
@@ -14,39 +14,49 @@ from .message import Message
|
||||
from .widget import Widget
|
||||
|
||||
|
||||
class ScrollMessage(Message, bubble=False):
|
||||
pass
|
||||
|
||||
|
||||
@rich.repr.auto
|
||||
class ScrollUp(Message):
|
||||
class ScrollUp(ScrollMessage):
|
||||
"""Message sent when clicking above handle."""
|
||||
|
||||
|
||||
@rich.repr.auto
|
||||
class ScrollDown(Message):
|
||||
class ScrollDown(ScrollMessage):
|
||||
"""Message sent when clicking below handle."""
|
||||
|
||||
|
||||
@rich.repr.auto
|
||||
class ScrollLeft(Message):
|
||||
class ScrollLeft(ScrollMessage):
|
||||
"""Message sent when clicking above handle."""
|
||||
|
||||
|
||||
@rich.repr.auto
|
||||
class ScrollRight(Message):
|
||||
class ScrollRight(ScrollMessage):
|
||||
"""Message sent when clicking below handle."""
|
||||
|
||||
|
||||
class ScrollTo(Message):
|
||||
class ScrollTo(ScrollMessage):
|
||||
"""Message sent when click and dragging handle."""
|
||||
|
||||
def __init__(
|
||||
self, sender: MessageTarget, x: float | None = None, y: float | None = None
|
||||
self,
|
||||
sender: MessageTarget,
|
||||
x: float | None = None,
|
||||
y: float | None = None,
|
||||
animate: bool = True,
|
||||
) -> None:
|
||||
self.x = x
|
||||
self.y = y
|
||||
self.animate = animate
|
||||
super().__init__(sender)
|
||||
|
||||
def __rich_repr__(self) -> rich.repr.Result:
|
||||
yield "x", self.x, None
|
||||
yield "y", self.y, None
|
||||
yield "animate", self.animate, True
|
||||
|
||||
|
||||
class ScrollBarRender:
|
||||
@@ -180,7 +190,7 @@ class ScrollBar(Widget):
|
||||
self.grabbed_position: float = 0
|
||||
super().__init__(name=name)
|
||||
|
||||
virtual_size: Reactive[int] = Reactive(100)
|
||||
window_virtual_size: Reactive[int] = Reactive(100)
|
||||
window_size: Reactive[int] = Reactive(0)
|
||||
position: Reactive[int] = Reactive(0)
|
||||
mouse_over: Reactive[bool] = Reactive(False)
|
||||
@@ -188,7 +198,7 @@ class ScrollBar(Widget):
|
||||
|
||||
def __rich_repr__(self) -> rich.repr.Result:
|
||||
yield from super().__rich_repr__()
|
||||
yield "virtual_size", self.virtual_size
|
||||
yield "window_virtual_size", self.window_virtual_size
|
||||
yield "window_size", self.window_size
|
||||
yield "position", self.position
|
||||
|
||||
@@ -198,7 +208,7 @@ class ScrollBar(Widget):
|
||||
color=Color.parse("bright_yellow" if self.grabbed else "bright_magenta"),
|
||||
)
|
||||
return ScrollBarRender(
|
||||
virtual_size=self.virtual_size,
|
||||
virtual_size=self.window_virtual_size,
|
||||
window_size=self.window_size,
|
||||
position=self.position,
|
||||
vertical=self.vertical,
|
||||
@@ -246,7 +256,7 @@ class ScrollBar(Widget):
|
||||
self.grabbed_position
|
||||
+ (
|
||||
(event.screen_y - self.grabbed.y)
|
||||
* (self.virtual_size / self.window_size)
|
||||
* (self.window_virtual_size / self.window_size)
|
||||
)
|
||||
)
|
||||
else:
|
||||
@@ -254,10 +264,10 @@ class ScrollBar(Widget):
|
||||
self.grabbed_position
|
||||
+ (
|
||||
(event.screen_x - self.grabbed.x)
|
||||
* (self.virtual_size / self.window_size)
|
||||
* (self.window_virtual_size / self.window_size)
|
||||
)
|
||||
)
|
||||
await self.emit(ScrollTo(self, x=x, y=y))
|
||||
await self.emit(ScrollTo(self, x=x, y=y, animate=False))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -72,6 +72,7 @@ class Widget(DOMNode):
|
||||
) -> None:
|
||||
|
||||
self._size = Size(0, 0)
|
||||
self._virtual_size = Size(0, 0)
|
||||
self._repaint_required = False
|
||||
self._layout_required = False
|
||||
self._animate: BoundAnimator | None = None
|
||||
@@ -92,7 +93,7 @@ class Widget(DOMNode):
|
||||
scroll_y = Reactive(0.0)
|
||||
scroll_target_x = Reactive(0.0)
|
||||
scroll_target_y = Reactive(0.0)
|
||||
virtual_size = Reactive(Size(0, 0))
|
||||
# virtual_size = Reactive(Size(0, 0))
|
||||
show_vertical_scrollbar = Reactive(False)
|
||||
show_horizontal_scrollbar = Reactive(False)
|
||||
|
||||
@@ -114,14 +115,14 @@ class Widget(DOMNode):
|
||||
def validate_scroll_target_y(self, value: float) -> float:
|
||||
return clamp(value, 0, self.max_scroll_y)
|
||||
|
||||
@property
|
||||
def max_scroll_y(self) -> float:
|
||||
return max(0, self.virtual_size.height - self.size.height)
|
||||
|
||||
@property
|
||||
def max_scroll_x(self) -> float:
|
||||
return max(0, self.virtual_size.width - self.size.width)
|
||||
|
||||
@property
|
||||
def max_scroll_y(self) -> float:
|
||||
return max(0, self.virtual_size.height - self.size.height)
|
||||
|
||||
@property
|
||||
def vscroll(self) -> ScrollBar:
|
||||
"""Get a vertical scrollbar (create if necessary)
|
||||
@@ -169,6 +170,66 @@ class Widget(DOMNode):
|
||||
return False, False
|
||||
return self.show_vertical_scrollbar, self.show_horizontal_scrollbar
|
||||
|
||||
def scroll_to(
|
||||
self,
|
||||
x: float | None = None,
|
||||
y: float | None = None,
|
||||
*,
|
||||
animate: bool = True,
|
||||
):
|
||||
"""Scroll to a given (absolute) coordinate, optionally animating.
|
||||
|
||||
Args:
|
||||
scroll_x (int | None, optional): X coordinate (column) to scroll to, or ``None`` for no change. Defaults to None.
|
||||
scroll_y (int | None, optional): Y coordinate (row) to scroll to, or ``None`` for no change. Defaults to None.
|
||||
animate (bool, optional): Animate to new scroll position. Defaults to False.
|
||||
"""
|
||||
|
||||
scroll_limit = False
|
||||
|
||||
if animate:
|
||||
if x is not None:
|
||||
self.scroll_target_x = x
|
||||
if y is not None:
|
||||
self.scroll_target_y = y
|
||||
# TODO: Speed to be configurable or a setting
|
||||
self.animate(
|
||||
"scroll_x", self.scroll_target_x, speed=100, easing="out_cubic"
|
||||
)
|
||||
self.animate(
|
||||
"scroll_y", self.scroll_target_y, speed=100, easing="out_cubic"
|
||||
)
|
||||
else:
|
||||
if x is not None:
|
||||
self.scroll_x = x
|
||||
if y is not None:
|
||||
self.scroll_y = y
|
||||
self.refresh(layout=True)
|
||||
|
||||
def scroll_home(self, animate: bool = True) -> None:
|
||||
self.scroll_to(0, 0, animate=animate)
|
||||
|
||||
def scroll_end(self, animate: bool = True) -> None:
|
||||
self.scroll_to(0, self.max_scroll_y, animate=animate)
|
||||
|
||||
def scroll_up(self, animate: bool = True) -> None:
|
||||
self.scroll_to(y=self.scroll_target_y + 1.5, animate=animate)
|
||||
|
||||
def scroll_down(self, animate: bool = True) -> None:
|
||||
self.scroll_to(y=self.scroll_target_y - 1.5, animate=animate)
|
||||
|
||||
def scroll_page_up(self, animate: bool = True) -> None:
|
||||
self.scroll_to(y=self.scroll_target_y - self.size.height, animate=animate)
|
||||
|
||||
def scroll_page_down(self, animate: bool = True) -> None:
|
||||
self.scroll_to(y=self.scroll_target_y + self.size.height, animate=animate)
|
||||
|
||||
def scroll_page_left(self, animate: bool = True) -> None:
|
||||
self.scroll_to(x=self.scroll_target_x - self.size.width, animate=animate)
|
||||
|
||||
def scroll_page_right(self, animate: bool = True) -> None:
|
||||
self.scroll_to(x=self.scroll_target_x + self.size.width, animate=animate)
|
||||
|
||||
def __init_subclass__(cls, can_focus: bool = True) -> None:
|
||||
super().__init_subclass__()
|
||||
cls.can_focus = can_focus
|
||||
@@ -213,7 +274,6 @@ class Widget(DOMNode):
|
||||
yield "hover"
|
||||
if self.has_focus:
|
||||
yield "focus"
|
||||
# TODO: focus
|
||||
|
||||
def watch(self, attribute_name, callback: Callable[[Any], Awaitable[None]]) -> None:
|
||||
watch(self, attribute_name, callback)
|
||||
@@ -301,6 +361,15 @@ class Widget(DOMNode):
|
||||
def layout(self) -> Layout | None:
|
||||
return self.styles.layout
|
||||
|
||||
@property
|
||||
def is_container(self) -> bool:
|
||||
"""Check if this widget is a container (contains other widgets)
|
||||
|
||||
Returns:
|
||||
bool: True if this widget is a container.
|
||||
"""
|
||||
return self.styles.layout is not None
|
||||
|
||||
def watch_mouse_over(self, value: bool) -> None:
|
||||
"""Update from CSS if mouse over state changes."""
|
||||
self.app.update_styles()
|
||||
@@ -313,13 +382,19 @@ class Widget(DOMNode):
|
||||
self.clear_render_cache()
|
||||
|
||||
def size_updated(self, size: Size, virtual_size: Size) -> None:
|
||||
self._size = size
|
||||
self._virtual_size = virtual_size
|
||||
if self.show_vertical_scrollbar:
|
||||
self.vscroll.virtual_size = virtual_size.height
|
||||
self.vscroll.window_size = size.height
|
||||
self.vscroll.refresh()
|
||||
self.log(virtual_size.height, size.height)
|
||||
if self._size != size or self._virtual_size != virtual_size:
|
||||
self._size = size
|
||||
self._virtual_size = virtual_size
|
||||
if self.show_vertical_scrollbar:
|
||||
self.vscroll.window_virtual_size = virtual_size.height
|
||||
self.vscroll.window_size = size.height
|
||||
self.vscroll.refresh()
|
||||
if self.show_horizontal_scrollbar:
|
||||
self.hscroll.window_virtual_size = virtual_size.width
|
||||
self.hscroll.window_size = size.width
|
||||
self.hscroll.refresh()
|
||||
|
||||
self.refresh()
|
||||
|
||||
def render_lines(self) -> None:
|
||||
width, height = self.size
|
||||
@@ -405,7 +480,6 @@ class Widget(DOMNode):
|
||||
|
||||
async def on_resize(self, event: events.Resize) -> None:
|
||||
self.size_updated(event.size, event.virtual_size)
|
||||
self.refresh()
|
||||
|
||||
async def on_idle(self, event: events.Idle) -> None:
|
||||
repaint, layout = self.styles.check_refresh()
|
||||
@@ -453,15 +527,61 @@ class Widget(DOMNode):
|
||||
await self.broker_event("click", event)
|
||||
|
||||
async def on_key(self, event: events.Key) -> None:
|
||||
if await self.dispatch_key(event):
|
||||
event.prevent_default()
|
||||
await self.dispatch_key(event)
|
||||
|
||||
async def handle_scroll_to(self, message: ScrollTo) -> None:
|
||||
if message.x is not None:
|
||||
self.scroll_target_x = message.x
|
||||
if message.y is not None:
|
||||
self.scroll_target_y = message.y
|
||||
message.stop()
|
||||
# self.refresh(layout=True)
|
||||
self.animate("scroll_x", self.scroll_target_x, speed=150, easing="out_cubic")
|
||||
self.animate("scroll_y", self.scroll_target_y, speed=150, easing="out_cubic")
|
||||
def on_mouse_scroll_down(self) -> None:
|
||||
self.scroll_down()
|
||||
|
||||
def on_mouse_scroll_up(self) -> None:
|
||||
self.scroll_up()
|
||||
|
||||
def handle_scroll_to(self, message: ScrollTo) -> None:
|
||||
self.scroll_to(message.x, message.y, animate=message.animate)
|
||||
|
||||
def handle_scroll_up(self, event) -> None:
|
||||
self.scroll_page_up()
|
||||
|
||||
def handle_scroll_down(self) -> None:
|
||||
self.scroll_page_down()
|
||||
|
||||
def handle_scroll_left(self) -> None:
|
||||
self.scroll_page_left()
|
||||
|
||||
def handle_scroll_right(self) -> None:
|
||||
self.scroll_page_right()
|
||||
|
||||
def key_home(self) -> bool:
|
||||
if self.is_container:
|
||||
self.scroll_home()
|
||||
return True
|
||||
return False
|
||||
|
||||
def key_end(self) -> bool:
|
||||
if self.is_container:
|
||||
self.scroll_end()
|
||||
return True
|
||||
return False
|
||||
|
||||
def key_down(self) -> bool:
|
||||
if self.is_container:
|
||||
self.scroll_down()
|
||||
return True
|
||||
return False
|
||||
|
||||
def key_up(self) -> bool:
|
||||
if self.is_container:
|
||||
self.scroll_up()
|
||||
return True
|
||||
return False
|
||||
|
||||
def key_pagedown(self) -> bool:
|
||||
if self.is_container:
|
||||
self.scroll_page_down()
|
||||
return True
|
||||
return False
|
||||
|
||||
def key_pageup(self) -> bool:
|
||||
if self.is_container:
|
||||
self.scroll_page_up()
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -210,9 +210,9 @@ class ScrollView(Screen):
|
||||
self.x = self.validate_x(self.x)
|
||||
self.y = self.validate_y(self.y)
|
||||
|
||||
self.hscroll.virtual_size = virtual_width
|
||||
self.hscroll.window_virtual_size = virtual_width
|
||||
self.hscroll.window_size = width
|
||||
self.vscroll.virtual_size = virtual_height
|
||||
self.vscroll.window_virtual_size = virtual_height
|
||||
self.vscroll.window_size = height
|
||||
|
||||
assert isinstance(self.layout, GridLayout)
|
||||
|
||||
Reference in New Issue
Block a user