jnav 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- jnav/__init__.py +0 -0
- jnav/__main__.py +108 -0
- jnav/app.py +398 -0
- jnav/buffer.py +48 -0
- jnav/detail_tree.py +242 -0
- jnav/field_mapping.py +136 -0
- jnav/filter_manager_screen.py +100 -0
- jnav/filter_provider.py +103 -0
- jnav/filter_tree.py +344 -0
- jnav/filtering.py +191 -0
- jnav/header.py +48 -0
- jnav/help_screen.py +79 -0
- jnav/inline_tree.py +75 -0
- jnav/key_sequences.py +211 -0
- jnav/log_entry_item.py +96 -0
- jnav/log_entry_renderer.py +90 -0
- jnav/log_list_view.py +292 -0
- jnav/log_model.py +114 -0
- jnav/logging.py +52 -0
- jnav/manager_screen_common.py +27 -0
- jnav/model.py +13 -0
- jnav/offset_group.py +30 -0
- jnav/parsing.py +101 -0
- jnav/reading.py +52 -0
- jnav/role_mapper.py +53 -0
- jnav/scrollbar_overlay.py +79 -0
- jnav/search_engine.py +87 -0
- jnav/selector_manager_screen.py +187 -0
- jnav/selector_provider.py +64 -0
- jnav/store.py +37 -0
- jnav/text_input_screen.py +63 -0
- jnav/tree_rendering.py +194 -0
- jnav/virtual_list_view.py +383 -0
- jnav-1.0.0.dist-info/METADATA +186 -0
- jnav-1.0.0.dist-info/RECORD +37 -0
- jnav-1.0.0.dist-info/WHEEL +4 -0
- jnav-1.0.0.dist-info/entry_points.txt +3 -0
jnav/tree_rendering.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
from collections.abc import Callable
|
|
2
|
+
from typing import Any, Protocol, cast
|
|
3
|
+
|
|
4
|
+
from rich.style import Style
|
|
5
|
+
from rich.text import Text
|
|
6
|
+
|
|
7
|
+
PRIORITY_KEYS = ("timestamp", "ts", "time", "level", "severity", "message", "msg")
|
|
8
|
+
|
|
9
|
+
DEFAULT_JSON_STRING_STYLE = "orange3 italic"
|
|
10
|
+
DEFAULT_SEARCH_HIGHLIGHT_STYLE = "on dark_orange3"
|
|
11
|
+
DEFAULT_VALUE_STYLE = ""
|
|
12
|
+
DEFAULT_VALUE_NULL_STYLE = "dim italic"
|
|
13
|
+
|
|
14
|
+
AddBranchFn = Callable[[Any, Text, str, object], Any]
|
|
15
|
+
AddLeafFn = Callable[[Any, Text, str, object], None]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class TreeVisitor(Protocol):
|
|
19
|
+
def enter_property(
|
|
20
|
+
self, key: str, value: dict[str, Any] | list[object], path: str, from_json: bool
|
|
21
|
+
) -> None: ...
|
|
22
|
+
def exit_property(self) -> None: ...
|
|
23
|
+
def on_property(self, key: str, value: object, path: str) -> None: ...
|
|
24
|
+
def enter_item(
|
|
25
|
+
self, index: int, value: dict[str, Any] | list[object], path: str
|
|
26
|
+
) -> None: ...
|
|
27
|
+
def exit_item(self) -> None: ...
|
|
28
|
+
def on_item(self, index: int, value: object, path: str) -> None: ...
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def sorted_keys(d: dict[str, Any]) -> list[str]:
|
|
32
|
+
priority = [k for k in PRIORITY_KEYS if k in d]
|
|
33
|
+
rest = [k for k in d if k not in priority]
|
|
34
|
+
return priority + rest
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def oneline(value: object) -> str:
|
|
38
|
+
s = str(value)
|
|
39
|
+
if "\n" in s:
|
|
40
|
+
first = s[: s.index("\n")]
|
|
41
|
+
return first + "\u2026"
|
|
42
|
+
return s
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def highlight_text(
|
|
46
|
+
text: Text,
|
|
47
|
+
term: str | None,
|
|
48
|
+
style: str | Style = DEFAULT_SEARCH_HIGHLIGHT_STYLE,
|
|
49
|
+
) -> Text:
|
|
50
|
+
if not term:
|
|
51
|
+
return text
|
|
52
|
+
plain = text.plain.lower()
|
|
53
|
+
term_lower = term.lower()
|
|
54
|
+
start = 0
|
|
55
|
+
while True:
|
|
56
|
+
idx = plain.find(term_lower, start)
|
|
57
|
+
if idx == -1:
|
|
58
|
+
break
|
|
59
|
+
text.stylize(style, idx, idx + len(term_lower))
|
|
60
|
+
start = idx + 1
|
|
61
|
+
return text
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class TreeBuildVisitor[T]:
|
|
65
|
+
def __init__(
|
|
66
|
+
self,
|
|
67
|
+
*,
|
|
68
|
+
root: T,
|
|
69
|
+
add_branch: AddBranchFn,
|
|
70
|
+
add_leaf: AddLeafFn,
|
|
71
|
+
selected: set[str],
|
|
72
|
+
key_style: str | Style,
|
|
73
|
+
selected_style: str | Style,
|
|
74
|
+
value_style: str | Style = DEFAULT_VALUE_STYLE,
|
|
75
|
+
value_null_style: str | Style = DEFAULT_VALUE_NULL_STYLE,
|
|
76
|
+
json_string_style: str | Style = DEFAULT_JSON_STRING_STYLE,
|
|
77
|
+
search_highlight_style: str | Style = DEFAULT_SEARCH_HIGHLIGHT_STYLE,
|
|
78
|
+
search_term: str | None = "",
|
|
79
|
+
) -> None:
|
|
80
|
+
self._stack: list[T] = [root]
|
|
81
|
+
self._add_branch = add_branch
|
|
82
|
+
self._add_leaf = add_leaf
|
|
83
|
+
self._selected = selected
|
|
84
|
+
self._key_style = key_style
|
|
85
|
+
self._selected_style = selected_style
|
|
86
|
+
self._value_style = value_style
|
|
87
|
+
self._value_null_style = value_null_style
|
|
88
|
+
self._json_string_style = json_string_style
|
|
89
|
+
self._search_highlight_style = search_highlight_style
|
|
90
|
+
self._search_term = search_term
|
|
91
|
+
|
|
92
|
+
def _style_for(self, path: str) -> str | Style:
|
|
93
|
+
return self._selected_style if path in self._selected else self._key_style
|
|
94
|
+
|
|
95
|
+
def _val_style(self, value: object) -> str | Style:
|
|
96
|
+
return self._value_null_style if value is None else self._value_style
|
|
97
|
+
|
|
98
|
+
def _hl(self, text: Text) -> Text:
|
|
99
|
+
return (
|
|
100
|
+
highlight_text(text, self._search_term, self._search_highlight_style)
|
|
101
|
+
if self._search_term
|
|
102
|
+
else text
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
def enter_property(
|
|
106
|
+
self, key: str, value: dict[str, Any] | list[object], path: str, from_json: bool
|
|
107
|
+
) -> None:
|
|
108
|
+
style = self._style_for(path)
|
|
109
|
+
if isinstance(value, dict):
|
|
110
|
+
indicator = '"{}"' if from_json else "{}"
|
|
111
|
+
else:
|
|
112
|
+
n = len(value)
|
|
113
|
+
indicator = f'"[{n} items]"' if from_json else f"[{n} items]"
|
|
114
|
+
ind_style = self._json_string_style if from_json else "dim"
|
|
115
|
+
label = self._hl(
|
|
116
|
+
Text.assemble((key, style), (": ", "dim"), (indicator, ind_style))
|
|
117
|
+
)
|
|
118
|
+
new_node = self._add_branch(self._stack[-1], label, path, value)
|
|
119
|
+
self._stack.append(new_node)
|
|
120
|
+
|
|
121
|
+
def exit_property(self) -> None:
|
|
122
|
+
self._stack.pop()
|
|
123
|
+
|
|
124
|
+
def on_property(self, key: str, value: object, path: str) -> None:
|
|
125
|
+
style = self._style_for(path)
|
|
126
|
+
label = self._hl(
|
|
127
|
+
Text.assemble(
|
|
128
|
+
(key, style), (": ", "dim"), (oneline(value), self._val_style(value))
|
|
129
|
+
)
|
|
130
|
+
)
|
|
131
|
+
label.no_wrap = True
|
|
132
|
+
label.overflow = "ellipsis"
|
|
133
|
+
self._add_leaf(self._stack[-1], label, path, value)
|
|
134
|
+
|
|
135
|
+
def enter_item(
|
|
136
|
+
self, index: int, value: dict[str, Any] | list[object], path: str
|
|
137
|
+
) -> None:
|
|
138
|
+
label = self._hl(Text(f"[{index}]", style="dim"))
|
|
139
|
+
new_node = self._add_branch(self._stack[-1], label, path, value)
|
|
140
|
+
self._stack.append(new_node)
|
|
141
|
+
|
|
142
|
+
def exit_item(self) -> None:
|
|
143
|
+
self._stack.pop()
|
|
144
|
+
|
|
145
|
+
def on_item(self, index: int, value: object, path: str) -> None:
|
|
146
|
+
label = self._hl(
|
|
147
|
+
Text.assemble(
|
|
148
|
+
(f"[{index}]", "dim"),
|
|
149
|
+
(": ", "dim"),
|
|
150
|
+
(oneline(value), self._val_style(value)),
|
|
151
|
+
)
|
|
152
|
+
)
|
|
153
|
+
label.no_wrap = True
|
|
154
|
+
label.overflow = "ellipsis"
|
|
155
|
+
self._add_leaf(self._stack[-1], label, path, value)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def walk_tree(
|
|
159
|
+
*,
|
|
160
|
+
value: object,
|
|
161
|
+
path: str,
|
|
162
|
+
visitor: TreeVisitor,
|
|
163
|
+
json_paths: set[str] | None = None,
|
|
164
|
+
) -> None:
|
|
165
|
+
jp = json_paths or set()
|
|
166
|
+
|
|
167
|
+
if isinstance(value, dict):
|
|
168
|
+
value = cast(dict[str, Any], value)
|
|
169
|
+
for k in sorted_keys(value):
|
|
170
|
+
v = value[k]
|
|
171
|
+
child_path = f"{path}.{k}" if path else k
|
|
172
|
+
if isinstance(v, (dict, list)):
|
|
173
|
+
v = cast(dict[str, Any] | list[object], v)
|
|
174
|
+
from_json = child_path in jp
|
|
175
|
+
visitor.enter_property(k, v, child_path, from_json)
|
|
176
|
+
walk_tree(
|
|
177
|
+
value=v, path=child_path, visitor=visitor, json_paths=json_paths
|
|
178
|
+
)
|
|
179
|
+
visitor.exit_property()
|
|
180
|
+
else:
|
|
181
|
+
visitor.on_property(k, v, child_path)
|
|
182
|
+
elif isinstance(value, list):
|
|
183
|
+
value = cast(list[object], value)
|
|
184
|
+
for i, item in enumerate(value):
|
|
185
|
+
child_path = f"{path}[{i}]"
|
|
186
|
+
if isinstance(item, (dict, list)):
|
|
187
|
+
item = cast(dict[str, Any] | list[object], item)
|
|
188
|
+
visitor.enter_item(i, item, child_path)
|
|
189
|
+
walk_tree(
|
|
190
|
+
value=item, path=child_path, visitor=visitor, json_paths=json_paths
|
|
191
|
+
)
|
|
192
|
+
visitor.exit_item()
|
|
193
|
+
else:
|
|
194
|
+
visitor.on_item(i, item, child_path)
|
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
from collections.abc import Callable, Sequence
|
|
2
|
+
from typing import TYPE_CHECKING, Any, ClassVar, Literal, override
|
|
3
|
+
|
|
4
|
+
from rich.console import RenderableType
|
|
5
|
+
from textual import getters
|
|
6
|
+
from textual.app import App
|
|
7
|
+
from textual.binding import Binding, BindingType
|
|
8
|
+
from textual.message import Message
|
|
9
|
+
from textual.reactive import reactive
|
|
10
|
+
from textual.widget import Widget
|
|
11
|
+
|
|
12
|
+
from jnav.model import Model
|
|
13
|
+
|
|
14
|
+
from .offset_group import OffsetGroup
|
|
15
|
+
from .scrollbar_overlay import ScrollbarOverlay
|
|
16
|
+
|
|
17
|
+
type RenderItemFn[T] = Callable[[T, int], RenderableType]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class VirtualListView[T](Widget, can_focus=True):
|
|
21
|
+
"""A virtualized list that renders only visible items.
|
|
22
|
+
|
|
23
|
+
Instead of creating a widget per item (like Textual's ListView), this widget
|
|
24
|
+
calls a `render_item` callback to produce Rich renderables for each visible item.
|
|
25
|
+
Supports variable-height items, keyboard/mouse scrolling, and a scrollbar overlay.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
render_item: Callback ``(item, index) -> RenderableType`` to render each item.
|
|
29
|
+
id: Optional widget ID.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
class Highlighted(Message):
|
|
33
|
+
"""Posted when the cursor moves to a new item."""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
virtual_list: VirtualListView[Any],
|
|
38
|
+
index: int,
|
|
39
|
+
item: object,
|
|
40
|
+
) -> None:
|
|
41
|
+
super().__init__()
|
|
42
|
+
self.virtual_list = virtual_list
|
|
43
|
+
self.index = index
|
|
44
|
+
self.item = item
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
@override
|
|
48
|
+
def control(self) -> VirtualListView[Any]:
|
|
49
|
+
return self.virtual_list
|
|
50
|
+
|
|
51
|
+
class Selected(Message):
|
|
52
|
+
"""Posted when the user presses Enter on the current item."""
|
|
53
|
+
|
|
54
|
+
def __init__(
|
|
55
|
+
self,
|
|
56
|
+
virtual_list: VirtualListView[Any],
|
|
57
|
+
index: int,
|
|
58
|
+
item: object,
|
|
59
|
+
) -> None:
|
|
60
|
+
super().__init__()
|
|
61
|
+
self.virtual_list = virtual_list
|
|
62
|
+
self.index = index
|
|
63
|
+
self.item = item
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
@override
|
|
67
|
+
def control(self) -> VirtualListView[Any]:
|
|
68
|
+
return self.virtual_list
|
|
69
|
+
|
|
70
|
+
COMPONENT_CLASSES: ClassVar[set[str]] = {
|
|
71
|
+
"scrollbar--thumb",
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
DEFAULT_CSS = """
|
|
75
|
+
VirtualListView {
|
|
76
|
+
& > .scrollbar--thumb { color: $surface-lighten-2; }
|
|
77
|
+
}
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
BINDINGS: ClassVar[list[BindingType]] = [
|
|
81
|
+
Binding("j", "cursor_down", show=False),
|
|
82
|
+
Binding("k", "cursor_up", show=False),
|
|
83
|
+
Binding("ctrl+d", "scroll_half_down", show=False),
|
|
84
|
+
Binding("ctrl+u", "scroll_half_up", show=False),
|
|
85
|
+
Binding("G", "jump_bottom", show=False),
|
|
86
|
+
Binding("enter", "select"),
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
index: reactive[int] = reactive(-1, always_update=True)
|
|
90
|
+
|
|
91
|
+
_model: Model[T]
|
|
92
|
+
_render_item: RenderItemFn[T]
|
|
93
|
+
_follow: bool
|
|
94
|
+
|
|
95
|
+
if TYPE_CHECKING:
|
|
96
|
+
app = getters.app(App[None])
|
|
97
|
+
|
|
98
|
+
def __init__(
|
|
99
|
+
self,
|
|
100
|
+
*,
|
|
101
|
+
model: Model[T],
|
|
102
|
+
render_item: RenderItemFn[T],
|
|
103
|
+
id: str | None = None,
|
|
104
|
+
follow: bool = False,
|
|
105
|
+
) -> None:
|
|
106
|
+
super().__init__(id=id)
|
|
107
|
+
self._model = model
|
|
108
|
+
self._scroll_top_index: int = 0
|
|
109
|
+
self._scroll_line_offset: int = 0
|
|
110
|
+
self._render_item = render_item
|
|
111
|
+
self._follow = follow
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def scroll_top_index(self) -> int:
|
|
115
|
+
return self._scroll_top_index
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def scroll_line_offset(self) -> int:
|
|
119
|
+
return self._scroll_line_offset
|
|
120
|
+
|
|
121
|
+
def validate_index(self, value: int) -> int:
|
|
122
|
+
if self._model.is_empty():
|
|
123
|
+
return 0
|
|
124
|
+
return max(0, min(value, self._model.count() - 1))
|
|
125
|
+
|
|
126
|
+
def watch_index(self, value: int) -> None:
|
|
127
|
+
self._ensure_cursor_visible()
|
|
128
|
+
self.refresh()
|
|
129
|
+
if not self._model.is_empty():
|
|
130
|
+
self.post_message(self.Highlighted(self, value, self._model.get(value)))
|
|
131
|
+
|
|
132
|
+
def cursor_down(self) -> None:
|
|
133
|
+
"""Move the cursor down by one item."""
|
|
134
|
+
if self.index < self._model.count() - 1:
|
|
135
|
+
self.index += 1
|
|
136
|
+
|
|
137
|
+
def cursor_up(self) -> None:
|
|
138
|
+
"""Move the cursor up by one item."""
|
|
139
|
+
if self.index > 0:
|
|
140
|
+
self.index -= 1
|
|
141
|
+
|
|
142
|
+
def cursor_viewport_offset(self) -> int:
|
|
143
|
+
"""Return the cursor's vertical offset from the top of the viewport, in lines.
|
|
144
|
+
|
|
145
|
+
Pair with ``scroll_to_cursor_offset`` to save and restore the cursor's
|
|
146
|
+
visual position across content changes.
|
|
147
|
+
"""
|
|
148
|
+
offset = -self._scroll_line_offset
|
|
149
|
+
for i in range(self._scroll_top_index, self.index):
|
|
150
|
+
offset += self._render_and_measure(i)
|
|
151
|
+
return offset
|
|
152
|
+
|
|
153
|
+
def scroll_to_cursor_offset(self, target_offset: int) -> None:
|
|
154
|
+
"""Scroll the viewport so the cursor is at ``target_offset`` lines from the top.
|
|
155
|
+
|
|
156
|
+
Walks backward from the cursor, accumulating item heights, to find which
|
|
157
|
+
item should be at the top of the viewport and how far into it to offset.
|
|
158
|
+
"""
|
|
159
|
+
lines = 0
|
|
160
|
+
i = self.index
|
|
161
|
+
while i > 0:
|
|
162
|
+
i -= 1
|
|
163
|
+
h = self._render_and_measure(i)
|
|
164
|
+
if lines + h > target_offset:
|
|
165
|
+
new_top = i
|
|
166
|
+
new_offset = h - (target_offset - lines)
|
|
167
|
+
if new_offset >= h:
|
|
168
|
+
new_top += 1
|
|
169
|
+
new_offset = 0
|
|
170
|
+
self._scroll_top_index = new_top
|
|
171
|
+
self._scroll_line_offset = new_offset
|
|
172
|
+
return
|
|
173
|
+
lines += h
|
|
174
|
+
self._scroll_top_index = 0
|
|
175
|
+
self._scroll_line_offset = 0
|
|
176
|
+
|
|
177
|
+
def _move_cursor_half_page(self, direction: Literal["up", "down"]) -> None:
|
|
178
|
+
half = max(1, self.size.height // 2)
|
|
179
|
+
step = 1 if direction == "down" else -1
|
|
180
|
+
stop = self._model.count() if step == 1 else -1
|
|
181
|
+
lines = 0
|
|
182
|
+
target = self.index
|
|
183
|
+
for i in range(self.index + step, stop, step):
|
|
184
|
+
lines += self._render_and_measure(i)
|
|
185
|
+
target = i
|
|
186
|
+
if lines >= half:
|
|
187
|
+
break
|
|
188
|
+
self.index = target
|
|
189
|
+
|
|
190
|
+
def scroll_half_down(self) -> None:
|
|
191
|
+
"""Move the cursor down by roughly half a viewport height."""
|
|
192
|
+
self._move_cursor_half_page("down")
|
|
193
|
+
|
|
194
|
+
def scroll_half_up(self) -> None:
|
|
195
|
+
"""Move the cursor up by roughly half a viewport height."""
|
|
196
|
+
self._move_cursor_half_page("up")
|
|
197
|
+
|
|
198
|
+
@property
|
|
199
|
+
def follow(self) -> bool:
|
|
200
|
+
return self._follow
|
|
201
|
+
|
|
202
|
+
def action_cursor_down(self) -> None:
|
|
203
|
+
self._follow = False
|
|
204
|
+
self.cursor_down()
|
|
205
|
+
|
|
206
|
+
def action_cursor_up(self) -> None:
|
|
207
|
+
self._follow = False
|
|
208
|
+
self.cursor_up()
|
|
209
|
+
|
|
210
|
+
def action_scroll_half_down(self) -> None:
|
|
211
|
+
self._follow = False
|
|
212
|
+
self.scroll_half_down()
|
|
213
|
+
|
|
214
|
+
def action_scroll_half_up(self) -> None:
|
|
215
|
+
self._follow = False
|
|
216
|
+
self.scroll_half_up()
|
|
217
|
+
|
|
218
|
+
def action_jump_top(self) -> None:
|
|
219
|
+
self._follow = False
|
|
220
|
+
self.index = 0
|
|
221
|
+
|
|
222
|
+
def action_jump_bottom(self) -> None:
|
|
223
|
+
self._follow = True
|
|
224
|
+
if self.count() > 0:
|
|
225
|
+
self.index = self.count() - 1
|
|
226
|
+
|
|
227
|
+
def _scroll_viewport_up(self) -> None:
|
|
228
|
+
self._scroll_line_offset -= 1
|
|
229
|
+
|
|
230
|
+
# Still within the current top item, just refresh
|
|
231
|
+
if self._scroll_line_offset >= 0:
|
|
232
|
+
self.refresh()
|
|
233
|
+
return
|
|
234
|
+
|
|
235
|
+
# No item above, stay at the top
|
|
236
|
+
if self._scroll_top_index == 0:
|
|
237
|
+
self._scroll_line_offset = 0
|
|
238
|
+
return
|
|
239
|
+
|
|
240
|
+
# Move to the item above
|
|
241
|
+
self._scroll_top_index -= 1
|
|
242
|
+
height = self._render_and_measure(self._scroll_top_index)
|
|
243
|
+
self._scroll_line_offset = height - 1
|
|
244
|
+
|
|
245
|
+
self.refresh()
|
|
246
|
+
|
|
247
|
+
def _scroll_viewport_down(self) -> None:
|
|
248
|
+
|
|
249
|
+
# if the number of items below the current top is greater or equal the height of the widget, we can just scroll down without measuring
|
|
250
|
+
remaining_items = self.count() - self._scroll_top_index
|
|
251
|
+
can_scroll = remaining_items > self.size.height
|
|
252
|
+
|
|
253
|
+
if not can_scroll:
|
|
254
|
+
# we need to measure the remaining items to see if we have enough content to scroll
|
|
255
|
+
height = 0
|
|
256
|
+
for i in range(self._scroll_top_index, self.count()):
|
|
257
|
+
height += self._render_and_measure(i)
|
|
258
|
+
can_scroll = height > self.size.height
|
|
259
|
+
|
|
260
|
+
if not can_scroll:
|
|
261
|
+
return
|
|
262
|
+
|
|
263
|
+
self._scroll_line_offset += 1
|
|
264
|
+
|
|
265
|
+
height = self._render_and_measure(self._scroll_top_index)
|
|
266
|
+
|
|
267
|
+
# End of the current item, move to the next one
|
|
268
|
+
if self._scroll_line_offset == height:
|
|
269
|
+
self._scroll_top_index += 1
|
|
270
|
+
self._scroll_line_offset = 0
|
|
271
|
+
|
|
272
|
+
self.refresh()
|
|
273
|
+
|
|
274
|
+
def on_mouse_scroll_down(self) -> None:
|
|
275
|
+
self._follow = False
|
|
276
|
+
self._scroll_viewport_down()
|
|
277
|
+
|
|
278
|
+
def on_mouse_scroll_up(self) -> None:
|
|
279
|
+
self._follow = False
|
|
280
|
+
self._scroll_viewport_up()
|
|
281
|
+
|
|
282
|
+
def action_select(self) -> None:
|
|
283
|
+
if not self._model.is_empty():
|
|
284
|
+
msg = self.Selected(self, self.index, self._model.get(self.index))
|
|
285
|
+
self.post_message(msg)
|
|
286
|
+
|
|
287
|
+
def _render_and_measure(self, index: int) -> int:
|
|
288
|
+
renderable = self._render_item(self._model.get(index), index)
|
|
289
|
+
return self._measure_height(renderable)
|
|
290
|
+
|
|
291
|
+
def _ensure_cursor_visible(self) -> None:
|
|
292
|
+
if self._model.is_empty() or not self.size.height:
|
|
293
|
+
return
|
|
294
|
+
if self.index < self._scroll_top_index:
|
|
295
|
+
self._scroll_top_index = self.index
|
|
296
|
+
self._scroll_line_offset = 0
|
|
297
|
+
return
|
|
298
|
+
lines = 0
|
|
299
|
+
i = self.index
|
|
300
|
+
while i >= 0:
|
|
301
|
+
h = self._render_and_measure(i)
|
|
302
|
+
if lines + h > self.size.height:
|
|
303
|
+
new_top = i
|
|
304
|
+
new_offset = lines + h - self.size.height
|
|
305
|
+
if new_offset >= h:
|
|
306
|
+
new_top += 1
|
|
307
|
+
new_offset = 0
|
|
308
|
+
if new_top > self._scroll_top_index or (
|
|
309
|
+
new_top == self._scroll_top_index
|
|
310
|
+
and new_offset > self._scroll_line_offset
|
|
311
|
+
):
|
|
312
|
+
self._scroll_top_index = new_top
|
|
313
|
+
self._scroll_line_offset = new_offset
|
|
314
|
+
return
|
|
315
|
+
lines += h
|
|
316
|
+
i -= 1
|
|
317
|
+
|
|
318
|
+
@override
|
|
319
|
+
def watch_has_focus(self, _has_focus: bool) -> None:
|
|
320
|
+
self.app.stylesheet.update_nodes([self], animate=True)
|
|
321
|
+
|
|
322
|
+
async def on_mount(self) -> None:
|
|
323
|
+
if self.index < 0 and not self._model.is_empty():
|
|
324
|
+
self.index = 0
|
|
325
|
+
await self._model.on_append.subscribe_async(self._on_model_append)
|
|
326
|
+
|
|
327
|
+
async def _on_model_append(self, _: Sequence[T]) -> None:
|
|
328
|
+
if self._follow:
|
|
329
|
+
self.index = self.count() - 1
|
|
330
|
+
elif self.index < 0 and not self._model.is_empty():
|
|
331
|
+
self.index = 0
|
|
332
|
+
self.refresh()
|
|
333
|
+
|
|
334
|
+
def count(self) -> int:
|
|
335
|
+
"""Return the total number of items."""
|
|
336
|
+
return self._model.count()
|
|
337
|
+
|
|
338
|
+
def scroll_to_item(self, index: int) -> None:
|
|
339
|
+
"""Scroll the viewport so that the item at ``index`` is at the top."""
|
|
340
|
+
if index < 0 or index >= self._model.count():
|
|
341
|
+
raise IndexError("Item index out of range")
|
|
342
|
+
self._scroll_top_index = index
|
|
343
|
+
self._scroll_line_offset = 0
|
|
344
|
+
self.refresh()
|
|
345
|
+
|
|
346
|
+
@override
|
|
347
|
+
def render(self) -> RenderableType:
|
|
348
|
+
if self._model.is_empty():
|
|
349
|
+
return ""
|
|
350
|
+
height = self.size.height
|
|
351
|
+
renderables: list[RenderableType] = []
|
|
352
|
+
lines_used = -self._scroll_line_offset
|
|
353
|
+
last_rendered = self._scroll_top_index
|
|
354
|
+
for i in range(self._scroll_top_index, self._model.count()):
|
|
355
|
+
item = self._model.get(i)
|
|
356
|
+
renderable = self._render_item(item, i)
|
|
357
|
+
item_height = self._measure_height(renderable)
|
|
358
|
+
renderables.append(renderable)
|
|
359
|
+
last_rendered = i
|
|
360
|
+
lines_used += item_height
|
|
361
|
+
if lines_used >= height:
|
|
362
|
+
break
|
|
363
|
+
|
|
364
|
+
inner: RenderableType = OffsetGroup(renderables, self._scroll_line_offset)
|
|
365
|
+
|
|
366
|
+
return ScrollbarOverlay(
|
|
367
|
+
inner,
|
|
368
|
+
total_items=self._model.count(),
|
|
369
|
+
visible_count=last_rendered - self._scroll_top_index + 1,
|
|
370
|
+
scroll_position=self._scroll_top_index,
|
|
371
|
+
thumb_style=self.get_component_rich_style(
|
|
372
|
+
"scrollbar--thumb",
|
|
373
|
+
partial=True,
|
|
374
|
+
),
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
def _measure_height(self, renderable: RenderableType) -> int:
|
|
378
|
+
console = self.app.console
|
|
379
|
+
options = console.options.update_width(self.size.width)
|
|
380
|
+
lines = 0
|
|
381
|
+
for segment in console.render(renderable, options):
|
|
382
|
+
lines += segment.text.count("\n")
|
|
383
|
+
return max(lines, 1)
|