Skip to content

Commit b8bcdd3

Browse files
committed
scroll view
1 parent 4cd2efd commit b8bcdd3

File tree

9 files changed

+198
-72
lines changed

9 files changed

+198
-72
lines changed

src/textual/app.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -324,21 +324,21 @@ async def on_startup(self, event: events.Startup) -> None:
324324
footer.add_key("b", "Toggle sidebar")
325325
footer.add_key("q", "Quit")
326326

327-
# readme_path = os.path.join(
328-
# os.path.dirname(os.path.abspath(__file__)), "richreadme.md"
329-
# )
327+
readme_path = os.path.join(
328+
os.path.dirname(os.path.abspath(__file__)), "richreadme.md"
329+
)
330330
# scroll_view = LayoutView()
331331
# scroll_bar = ScrollBar()
332-
# with open(readme_path, "rt") as fh:
333-
# readme = Markdown(fh.read(), hyperlinks=True, code_theme="fruity")
332+
with open(readme_path, "rt") as fh:
333+
readme = Markdown(fh.read(), hyperlinks=True, code_theme="fruity")
334334
# scroll_view.layout.split_column(
335335
# Layout(readme, ratio=1), Layout(scroll_bar, ratio=2, size=2)
336336
# )
337337
layout = Layout()
338338
layout.split_column(Layout(name="l1"), Layout(name="l2"))
339339
# sub_view = LayoutView(name="Sub view", layout=layout)
340340

341-
sub_view = ScrollView()
341+
sub_view = ScrollView(readme)
342342

343343
# await sub_view.mount_all(l1=Placeholder(), l2=Placeholder())
344344

src/textual/events.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,14 @@ def __init_subclass__(cls, bubble: bool = False) -> None:
2424
super().__init_subclass__(bubble=bubble)
2525

2626

27-
class NoneEvent(Event):
28-
pass
27+
class Null(Event):
28+
def can_batch(self, event: Event) -> bool:
29+
return isinstance(event, Null)
30+
31+
32+
class Repaint(Event):
33+
def can_batch(self, event: Event) -> bool:
34+
return isinstance(event, Repaint)
2935

3036

3137
class ShutdownRequest(Event):

src/textual/message_pump.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,8 @@ async def process_messages(self) -> None:
161161

162162
async def dispatch_message(self, message: Message) -> bool | None:
163163
if isinstance(message, events.Event):
164-
await self.on_event(message)
164+
if not isinstance(message, events.Null):
165+
await self.on_event(message)
165166
else:
166167
return await self.on_message(message)
167168
return False

src/textual/page.py

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,32 @@
11
from __future__ import annotations
22

33
from rich.console import Console, ConsoleOptions, RenderableType, RenderResult
4+
from rich.padding import Padding, PaddingDimensions
45
from rich.segment import Segment
56
from rich.style import StyleType
67

7-
from .geometry import Point
8+
from .geometry import Dimensions, Point
89
from .widget import Widget
910

1011

11-
class Page:
12+
class PageRender:
1213
def __init__(
1314
self,
1415
renderable: RenderableType,
1516
width: int | None = None,
1617
height: int | None = None,
1718
style: StyleType = "",
19+
padding: PaddingDimensions = 1,
1820
) -> None:
1921
self.renderable = renderable
2022
self.width = width
2123
self.height = height
2224
self.style = style
25+
self.padding = padding
2326
self.offset = Point(0, 0)
2427
self._render_width: int | None = None
2528
self._render_height: int | None = None
29+
self.size = Dimensions(0, 0)
2630
self._lines: list[list[Segment]] = []
2731

2832
def move_to(self, x: int = 0, y: int = 0) -> None:
@@ -38,11 +42,15 @@ def update(self, renderable: RenderableType) -> None:
3842
self.refresh()
3943

4044
def render(self, console: Console, options: ConsoleOptions) -> None:
41-
width = self._render_width = self.width or options.max_width or console.width
42-
height = self._render_height = self.height or options.height or None
43-
options = options.update_width(width)
45+
width = self.width or options.max_width or console.width
46+
height = self.height or options.height or None
47+
options = options.update_dimensions(width, None)
4448
style = console.get_style(self.style)
45-
self._lines = console.render_lines(self.renderable, options, style=style)
49+
renderable = self.renderable
50+
if self.padding:
51+
renderable = Padding(renderable, self.padding)
52+
self._lines[:] = console.render_lines(renderable, options, style=style)
53+
self.size = Dimensions(width, len(self._lines))
4654

4755
def __rich_console__(
4856
self, console: Console, options: ConsoleOptions
@@ -60,5 +68,22 @@ def __rich_console__(
6068
blank_line = [Segment(" " * width, style), Segment.line()]
6169
window_lines.extend(blank_line for _ in range(missing_lines))
6270

71+
new_line = Segment.line()
6372
for line in window_lines:
64-
yield from line
73+
yield from line
74+
yield new_line
75+
76+
77+
class Page(Widget):
78+
def __init__(
79+
self, renderable: RenderableType, name: str = None, style: StyleType = ""
80+
):
81+
self._page = PageRender(renderable, style=style)
82+
super().__init__(name=name)
83+
84+
@property
85+
def virtual_size(self) -> Dimensions:
86+
return self._page.size
87+
88+
def render(self) -> RenderableType:
89+
return self._page

src/textual/scrollbar.py

Lines changed: 64 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
from __future__ import annotations
22

3-
from math import ceil
4-
import logging
53

4+
import logging
65

6+
from rich.repr import rich_repr, RichReprResult
77
from rich.color import Color
88
from rich.style import Style
99
from rich.console import Console, ConsoleOptions, RenderResult, RenderableType
@@ -12,10 +12,10 @@
1212

1313
log = logging.getLogger("rich")
1414

15-
from .widget import Widget
15+
from .widget import Reactive, Widget
1616

1717

18-
class ScrollBar:
18+
class ScrollBarRender:
1919
def __init__(
2020
self,
2121
virtual_size: int = 100,
@@ -65,34 +65,39 @@ def render_bar(
6565
_Segment = Segment
6666
_Style = Style
6767
blank = " " * width_thickness
68-
segments = [_Segment(blank, _Style(bgcolor=back))] * int(size)
69-
70-
step_size = virtual_size / size
71-
72-
start = int(position / step_size * 8)
73-
end = start + max(8, int(window_size / step_size * 8))
74-
75-
start_index, start_bar = divmod(start, 8)
76-
end_index, end_bar = divmod(end, 8)
7768

78-
segments[start_index:end_index] = [_Segment(blank, _Style(bgcolor=bar))] * (
79-
end_index - start_index
80-
)
81-
82-
if start_index < len(segments):
83-
segments[start_index] = _Segment(
84-
bars[7 - start_bar] * width_thickness,
85-
_Style(bgcolor=back, color=bar)
86-
if vertical
87-
else _Style(bgcolor=bar, color=back),
88-
)
89-
if end_index < len(segments):
90-
segments[end_index] = _Segment(
91-
bars[7 - end_bar] * width_thickness,
92-
_Style(bgcolor=bar, color=back)
93-
if vertical
94-
else _Style(bgcolor=back, color=bar),
95-
)
69+
background_meta = {"background": True}
70+
foreground_meta = {"background": False}
71+
72+
back_segment = Segment(blank, _Style(bgcolor=back, meta=background_meta))
73+
segments = [back_segment] * int(size)
74+
if window_size and size and virtual_size:
75+
step_size = virtual_size / size
76+
77+
start = int(position / step_size * 8)
78+
end = start + max(8, int(window_size / step_size * 8))
79+
80+
start_index, start_bar = divmod(start, 8)
81+
end_index, end_bar = divmod(end, 8)
82+
83+
segments[start_index:end_index] = [
84+
_Segment(blank, _Style(bgcolor=bar, meta=foreground_meta))
85+
] * (end_index - start_index)
86+
87+
if start_index < len(segments):
88+
segments[start_index] = _Segment(
89+
bars[7 - start_bar] * width_thickness,
90+
_Style(bgcolor=back, color=bar, meta=foreground_meta)
91+
if vertical
92+
else _Style(bgcolor=bar, color=back, meta=foreground_meta),
93+
)
94+
if end_index < len(segments):
95+
segments[end_index] = _Segment(
96+
bars[7 - end_bar] * width_thickness,
97+
_Style(bgcolor=bar, color=back, meta=foreground_meta)
98+
if vertical
99+
else _Style(bgcolor=back, color=bar, meta=foreground_meta),
100+
)
96101
if vertical:
97102
return Segments(segments, new_lines=True)
98103
else:
@@ -127,11 +132,37 @@ def __rich_console__(
127132
yield bar
128133

129134

135+
@rich_repr
136+
class ScrollBar(Widget):
137+
def __init__(self, vertical: bool = True, name: str | None = None) -> None:
138+
self.vertical = vertical
139+
super().__init__(name=name)
140+
141+
virtual_size: Reactive[int] = Reactive(100)
142+
window_size: Reactive[int] = Reactive(20)
143+
position: Reactive[int] = Reactive(0)
144+
145+
def __rich_repr__(self) -> RichReprResult:
146+
yield "virtual_size", self.virtual_size
147+
yield "window_size", self.window_size
148+
yield "position", self.position
149+
150+
__rich_repr__.angular = True
151+
152+
def render(self) -> RenderableType:
153+
return ScrollBarRender(
154+
virtual_size=self.virtual_size,
155+
window_size=self.window_size,
156+
position=self.position,
157+
vertical=self.vertical,
158+
)
159+
160+
130161
if __name__ == "__main__":
131162
from rich.console import Console
132163
from rich.segment import Segments
133164

134165
console = Console()
135-
bar = ScrollBar()
166+
bar = ScrollBarRender()
136167

137-
console.print(ScrollBar(position=15.3, thickness=5, vertical=False))
168+
console.print(ScrollBarRender(position=15.3, thickness=5, vertical=False))

src/textual/state.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ def __init__(self, default: ValueType) -> None:
1010
self._default = default
1111

1212
def __set_name__(self, owner: object, name: str) -> None:
13-
self.internal_name = f"_{name}"
13+
self.internal_name = f"__{name}"
1414
setattr(owner, self.internal_name, self._default)
1515

1616
def __get__(self, obj: object, obj_type: Type[object]) -> ValueType:

src/textual/view.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
from .geometry import Dimensions, Region
1717
from .message import Message
1818
from .message_pump import MessagePump
19-
from .widget import Widget, WidgetBase, UpdateMessage
19+
from .widget import StaticWidget, Widget, WidgetBase, UpdateMessage
2020
from .widgets.header import Header
2121

2222
if TYPE_CHECKING:
@@ -45,7 +45,9 @@ def __rich_console__(
4545
yield
4646

4747
@abstractmethod
48-
async def mount(self, widget: Widget, *, slot: str = "main") -> None:
48+
async def mount(
49+
self, widget: WidgetBase | RenderableType, *, slot: str = "main"
50+
) -> None:
4951
...
5052

5153
async def mount_all(self, **widgets: Widget) -> None:
@@ -154,7 +156,12 @@ async def on_message(self, message: Message) -> None:
154156
# async def on_create(self, event: events.Created) -> None:
155157
# await self.mount(Header(self.title))
156158

157-
async def mount(self, widget: Widget, *, slot: str = "main") -> None:
159+
async def mount(
160+
self, widget: WidgetBase | RenderableType, *, slot: str = "main"
161+
) -> None:
162+
if not isinstance(widget, WidgetBase):
163+
log.debug("MOUNTED %r", widget)
164+
widget = StaticWidget(widget)
158165
self.layout[slot].update(widget)
159166
await self.app.add(widget)
160167
widget.set_parent(self)
@@ -222,7 +229,7 @@ async def forward_event(self, event: events.Event) -> None:
222229
await widget.forward_event(event.offset(-region.x, -region.y))
223230

224231
elif isinstance(event, (events.MouseScrollDown, events.MouseScrollUp)):
225-
widget, _region = self.get_widget_at(event.x, event.y)
232+
widget, _region = self.get_widget_at(event.x, event.y, deep=True)
226233
scroll_widget = widget or self.focused
227234
if scroll_widget is not None:
228235
await scroll_widget.forward_event(event)

src/textual/widget.py

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313

1414
from rich.align import Align
1515

16-
from rich.console import Console, ConsoleOptions, RenderableType
16+
from rich.console import Console, RenderableType
1717
from rich.pretty import Pretty
1818
from rich.panel import Panel
1919
import rich.repr
@@ -73,7 +73,8 @@ def __init__(
7373
self.validator = validator
7474

7575
def __set_name__(self, owner: "Widget", name: str) -> None:
76-
self.internal_name = f"_{name}"
76+
self.name = name
77+
self.internal_name = f"__{name}"
7778
setattr(owner, self.internal_name, self._default)
7879

7980
def __get__(self, obj: "Widget", obj_type: type[object]) -> ReactiveType:
@@ -82,20 +83,32 @@ def __get__(self, obj: "Widget", obj_type: type[object]) -> ReactiveType:
8283
def __set__(self, obj: "Widget", value: ReactiveType) -> None:
8384
if getattr(obj, self.internal_name) != value:
8485
log.debug("%s -> %s", self.internal_name, value)
85-
if self.validator:
86-
value = self.validator(obj, value)
87-
setattr(obj, self.internal_name, value)
88-
obj.require_repaint()
86+
87+
current_value = getattr(obj, self.internal_name, None)
88+
validate_function = getattr(obj, f"validate_{self.name}", None)
89+
if callable(validate_function):
90+
value = validate_function(value)
91+
92+
if current_value != value:
93+
setattr(obj, self.internal_name, value)
94+
95+
update_function = getattr(obj, f"update_{self.name}", None)
96+
if callable(update_function):
97+
update_function(current_value, value)
98+
99+
obj.post_message_no_wait(events.Repaint(obj))
89100

90101

91102
@rich.repr.auto
92103
class WidgetBase(MessagePump):
93-
_count: ClassVar[int] = 0
104+
_counts: ClassVar[dict[str, int]] = {}
94105
can_focus: bool = False
95106

96107
def __init__(self, name: str | None = None) -> None:
97-
self.name = name or f"{self.__class__.__name__}#{self._count}"
98-
Widget._count += 1
108+
Widget._counts.setdefault(self.__class__.__name__, 1)
109+
_count = self._counts[self.__class__.__name__]
110+
self.name = name or f"{self.__class__.__name__}#{_count}"
111+
99112
self.size = Dimensions(0, 0)
100113
self.size_changed = False
101114
self._repaint_required = False
@@ -201,6 +214,9 @@ async def on_idle(self, event: events.Idle) -> None:
201214
log.debug("REPAINTING")
202215
await self.repaint()
203216

217+
async def on_repaint(self, event: events.Repaint) -> None:
218+
self.require_repaint()
219+
204220

205221
class Widget(WidgetBase):
206222
def __init__(self, name: str | None = None) -> None:
@@ -259,10 +275,9 @@ async def on_mouse_move(self, event: events.MouseMove) -> None:
259275
log.debug("%r", self.get_style_at(event.x, event.y))
260276

261277
async def on_mouse_up(self, event: events.MouseUp) -> None:
262-
log.debug("CLICKED %r", event)
263278
style = self.get_style_at(event.x, event.y)
264-
log.debug(style.meta)
265279
if "@click" in style.meta:
280+
log.debug(style._link_id)
266281
await self.app.action(style.meta["@click"])
267282

268283

0 commit comments

Comments
 (0)