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/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)