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/role_mapper.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from aioreactive import AsyncSubject
|
|
4
|
+
|
|
5
|
+
from jnav.field_mapping import FieldMapping, detect_role_updates
|
|
6
|
+
from jnav.store import IndexedEntry
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class RoleMapper:
|
|
10
|
+
"""Tracks fields discovered in the data and the timestamp/level/message role mapping."""
|
|
11
|
+
|
|
12
|
+
on_change: AsyncSubject[None]
|
|
13
|
+
|
|
14
|
+
def __init__(self) -> None:
|
|
15
|
+
self._all_fields: set[str] = set()
|
|
16
|
+
self._mapping: FieldMapping = FieldMapping()
|
|
17
|
+
self.on_change = AsyncSubject[None]()
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
def all_fields(self) -> set[str]:
|
|
21
|
+
return self._all_fields
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def mapping(self) -> FieldMapping:
|
|
25
|
+
return self._mapping
|
|
26
|
+
|
|
27
|
+
async def discover(self, entries: list[IndexedEntry]) -> None:
|
|
28
|
+
"""Discover fields from a list of entries. Updates the mapping if new roles are detected."""
|
|
29
|
+
for ie in entries:
|
|
30
|
+
await self.discover_from_entry(ie.entry.expanded)
|
|
31
|
+
|
|
32
|
+
async def discover_from_entry(self, entry: dict[str, Any]) -> None:
|
|
33
|
+
"""Discover fields from a single entry. Updates the mapping if new roles are detected."""
|
|
34
|
+
new_fields = entry.keys() - self._all_fields
|
|
35
|
+
self._all_fields.update(new_fields)
|
|
36
|
+
|
|
37
|
+
updates = detect_role_updates(self._mapping, entry, new_fields)
|
|
38
|
+
|
|
39
|
+
if not updates:
|
|
40
|
+
return
|
|
41
|
+
|
|
42
|
+
self._mapping = self._mapping.model_copy(update=updates)
|
|
43
|
+
await self.on_change.asend(None)
|
|
44
|
+
|
|
45
|
+
async def set_mapping(
|
|
46
|
+
self,
|
|
47
|
+
mapping_data: dict[str, Any] | FieldMapping | None,
|
|
48
|
+
) -> None:
|
|
49
|
+
if mapping_data:
|
|
50
|
+
self._mapping = FieldMapping.model_validate(mapping_data)
|
|
51
|
+
else:
|
|
52
|
+
self._mapping = FieldMapping()
|
|
53
|
+
await self.on_change.asend(None)
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
from collections.abc import Generator
|
|
2
|
+
|
|
3
|
+
from rich.color import Color
|
|
4
|
+
from rich.console import Console, ConsoleOptions, RenderableType
|
|
5
|
+
from rich.segment import Segment
|
|
6
|
+
from rich.style import Style
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _get_bg_at(line: list[Segment], position: int) -> Color | None:
|
|
10
|
+
pos = 0
|
|
11
|
+
for seg in line:
|
|
12
|
+
pos += seg.cell_length
|
|
13
|
+
if pos > position:
|
|
14
|
+
return seg.style.bgcolor if seg.style else None
|
|
15
|
+
return None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ScrollbarOverlay:
|
|
19
|
+
"""A Rich renderable that overlays a scrollbar thumb on inner content.
|
|
20
|
+
|
|
21
|
+
Renders the inner content normally, but on lines within the thumb region,
|
|
22
|
+
truncates the line by one character and appends a scrollbar indicator.
|
|
23
|
+
The thumb character inherits the background color from the content beneath it.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
inner: RenderableType,
|
|
29
|
+
*,
|
|
30
|
+
total_items: int,
|
|
31
|
+
visible_count: int,
|
|
32
|
+
scroll_position: int,
|
|
33
|
+
thumb_style: Style,
|
|
34
|
+
) -> None:
|
|
35
|
+
self._inner = inner
|
|
36
|
+
self._total_items = total_items
|
|
37
|
+
self._visible_count = visible_count
|
|
38
|
+
self._scroll_position = scroll_position
|
|
39
|
+
self._thumb_style = thumb_style
|
|
40
|
+
|
|
41
|
+
def _compute_thumb(self, viewport_height: int) -> tuple[int, int]:
|
|
42
|
+
max_scroll = self._total_items - self._visible_count
|
|
43
|
+
if max_scroll <= 0:
|
|
44
|
+
return 0, 0
|
|
45
|
+
thumb_size = max(
|
|
46
|
+
1, round(viewport_height * self._visible_count / self._total_items)
|
|
47
|
+
)
|
|
48
|
+
fraction = self._scroll_position / max_scroll
|
|
49
|
+
thumb_top = round(fraction * (viewport_height - thumb_size))
|
|
50
|
+
thumb_top = max(0, min(thumb_top, viewport_height - thumb_size))
|
|
51
|
+
return thumb_top, thumb_top + thumb_size
|
|
52
|
+
|
|
53
|
+
def __rich_console__(
|
|
54
|
+
self,
|
|
55
|
+
console: Console,
|
|
56
|
+
options: ConsoleOptions,
|
|
57
|
+
) -> Generator[Segment]:
|
|
58
|
+
width = options.max_width
|
|
59
|
+
height = options.height or options.max_height
|
|
60
|
+
|
|
61
|
+
thumb_top, thumb_end = self._compute_thumb(height)
|
|
62
|
+
|
|
63
|
+
segments = console.render(self._inner, options)
|
|
64
|
+
for y, line in enumerate(Segment.split_lines(segments)):
|
|
65
|
+
# Yield the line as-is if it's outside the thumb region
|
|
66
|
+
if not (thumb_top <= y < thumb_end):
|
|
67
|
+
yield from line
|
|
68
|
+
yield Segment.line()
|
|
69
|
+
continue
|
|
70
|
+
|
|
71
|
+
# Truncate the line to make room for the scrollbar
|
|
72
|
+
truncated = Segment.adjust_line_length(list(line), width - 1)
|
|
73
|
+
yield from truncated
|
|
74
|
+
|
|
75
|
+
# Get the background color of the wrapped renderable
|
|
76
|
+
bg = _get_bg_at(line, width - 1)
|
|
77
|
+
|
|
78
|
+
yield Segment("┃", Style(color=self._thumb_style.color, bgcolor=bg))
|
|
79
|
+
yield Segment.line()
|
jnav/search_engine.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
from typing import Any, cast
|
|
2
|
+
|
|
3
|
+
from aioreactive import AsyncSubject
|
|
4
|
+
|
|
5
|
+
from jnav.log_model import LogModel
|
|
6
|
+
from jnav.store import IndexedEntry
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def entry_matches_search(entry: dict[str, Any], term_lower: str) -> bool:
|
|
10
|
+
def _check(obj: object) -> bool:
|
|
11
|
+
if isinstance(obj, str):
|
|
12
|
+
return term_lower in obj.lower()
|
|
13
|
+
if isinstance(obj, dict):
|
|
14
|
+
obj = cast(dict[str, Any], obj)
|
|
15
|
+
return any(_check(v) for v in obj.values())
|
|
16
|
+
if isinstance(obj, list):
|
|
17
|
+
obj = cast(list[Any], obj)
|
|
18
|
+
return any(_check(item) for item in obj)
|
|
19
|
+
return term_lower in str(obj).lower()
|
|
20
|
+
|
|
21
|
+
return _check(entry)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class SearchEngine:
|
|
25
|
+
on_change: AsyncSubject[None]
|
|
26
|
+
|
|
27
|
+
def __init__(self, model: LogModel) -> None:
|
|
28
|
+
self._model = model
|
|
29
|
+
self._term: str = ""
|
|
30
|
+
self._matches: list[int] = []
|
|
31
|
+
self.on_change = AsyncSubject[None]()
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def term(self) -> str:
|
|
35
|
+
return self._term
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def matches(self) -> list[int]:
|
|
39
|
+
return self._matches
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def active(self) -> bool:
|
|
43
|
+
return bool(self._term)
|
|
44
|
+
|
|
45
|
+
async def start(self) -> None:
|
|
46
|
+
await self._model.on_rebuild.subscribe_async(self._on_rebuild)
|
|
47
|
+
await self._model.on_append.subscribe_async(self._on_append)
|
|
48
|
+
|
|
49
|
+
async def set_term(self, term: str) -> None:
|
|
50
|
+
self._term = term
|
|
51
|
+
self._recompute_all()
|
|
52
|
+
await self.on_change.asend(None)
|
|
53
|
+
|
|
54
|
+
async def clear(self) -> None:
|
|
55
|
+
self._term = ""
|
|
56
|
+
self._matches = []
|
|
57
|
+
await self.on_change.asend(None)
|
|
58
|
+
|
|
59
|
+
def _recompute_all(self) -> None:
|
|
60
|
+
if not self._term:
|
|
61
|
+
self._matches = []
|
|
62
|
+
return
|
|
63
|
+
term_lower = self._term.lower()
|
|
64
|
+
self._matches = [
|
|
65
|
+
ie.index
|
|
66
|
+
for ie in self._model.all()
|
|
67
|
+
if entry_matches_search(ie.entry.expanded, term_lower)
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
async def _on_rebuild(self, _: None) -> None:
|
|
71
|
+
if not self._term:
|
|
72
|
+
return
|
|
73
|
+
self._recompute_all()
|
|
74
|
+
await self.on_change.asend(None)
|
|
75
|
+
|
|
76
|
+
async def _on_append(self, new_entries: list[IndexedEntry]) -> None:
|
|
77
|
+
if not self._term:
|
|
78
|
+
return
|
|
79
|
+
term_lower = self._term.lower()
|
|
80
|
+
new_matches = [
|
|
81
|
+
ie.index
|
|
82
|
+
for ie in new_entries
|
|
83
|
+
if entry_matches_search(ie.entry.expanded, term_lower)
|
|
84
|
+
]
|
|
85
|
+
if new_matches:
|
|
86
|
+
self._matches.extend(new_matches)
|
|
87
|
+
await self.on_change.asend(None)
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
from typing import TYPE_CHECKING, ClassVar, override
|
|
2
|
+
|
|
3
|
+
from rich.text import Text
|
|
4
|
+
from textual.app import ComposeResult
|
|
5
|
+
from textual.binding import Binding, BindingType
|
|
6
|
+
from textual.containers import Vertical
|
|
7
|
+
from textual.screen import ModalScreen
|
|
8
|
+
from textual.widgets import Footer, OptionList
|
|
9
|
+
from textual.widgets.option_list import Option
|
|
10
|
+
|
|
11
|
+
from jnav.manager_screen_common import list_option_prompt
|
|
12
|
+
from jnav.selector_provider import SelectorProvider
|
|
13
|
+
from jnav.text_input_screen import TextInputScreen
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from textual import getters
|
|
17
|
+
from textual.app import App
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SelectorManagerScreen(ModalScreen[bool]):
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
app = getters.app(App[None])
|
|
23
|
+
|
|
24
|
+
DEFAULT_CSS = """
|
|
25
|
+
SelectorManagerScreen {
|
|
26
|
+
align: center middle;
|
|
27
|
+
}
|
|
28
|
+
#selector-modal {
|
|
29
|
+
width: 60;
|
|
30
|
+
max-width: 90%;
|
|
31
|
+
height: auto;
|
|
32
|
+
max-height: 70%;
|
|
33
|
+
border: round $primary;
|
|
34
|
+
background: $background;
|
|
35
|
+
}
|
|
36
|
+
#selector-wrapper {
|
|
37
|
+
padding: 1 2;
|
|
38
|
+
height: auto;
|
|
39
|
+
}
|
|
40
|
+
#selector-list {
|
|
41
|
+
height: auto;
|
|
42
|
+
max-height: 14;
|
|
43
|
+
border: none;
|
|
44
|
+
}
|
|
45
|
+
#selector-modal Footer {
|
|
46
|
+
background: transparent;
|
|
47
|
+
}
|
|
48
|
+
#selector-modal FooterKey .footer-key--key {
|
|
49
|
+
color: $primary;
|
|
50
|
+
}
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
BINDINGS: ClassVar[list[BindingType]] = [
|
|
54
|
+
Binding("escape", "maybe_close", "Close", priority=True, show=False),
|
|
55
|
+
Binding("q", "maybe_close", show=False),
|
|
56
|
+
Binding("ctrl+c", "maybe_close", show=False),
|
|
57
|
+
Binding("a", "add", "Add"),
|
|
58
|
+
Binding("e", "edit", "Edit"),
|
|
59
|
+
Binding("d", "delete", "Cut"),
|
|
60
|
+
Binding("y", "yank", "Yank"),
|
|
61
|
+
Binding("p", "paste", "Paste"),
|
|
62
|
+
Binding("t", "toggle_item", "Toggle"),
|
|
63
|
+
Binding("j", "cursor_down", show=False),
|
|
64
|
+
Binding("k", "cursor_up", show=False),
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
def __init__(self, selector_provider: SelectorProvider) -> None:
|
|
68
|
+
super().__init__()
|
|
69
|
+
self._sp = selector_provider
|
|
70
|
+
self._clipboard: str | None = None
|
|
71
|
+
|
|
72
|
+
@override
|
|
73
|
+
def compose(self) -> ComposeResult:
|
|
74
|
+
yield Vertical(
|
|
75
|
+
Vertical(
|
|
76
|
+
OptionList(id="selector-list"),
|
|
77
|
+
id="selector-wrapper",
|
|
78
|
+
),
|
|
79
|
+
Footer(),
|
|
80
|
+
id="selector-modal",
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
def on_mount(self) -> None:
|
|
84
|
+
self.query_one("#selector-modal").border_title = "Selectors"
|
|
85
|
+
self._refresh_list(highlight=0)
|
|
86
|
+
self.query_one("#selector-list", OptionList).focus()
|
|
87
|
+
|
|
88
|
+
def _refresh_list(self, highlight: int | None = None) -> None:
|
|
89
|
+
selectors = self._sp.selectors
|
|
90
|
+
ol = self.query_one("#selector-list", OptionList)
|
|
91
|
+
ol.clear_options()
|
|
92
|
+
if not selectors:
|
|
93
|
+
ol.add_option(Option(Text(" (no selectors)", style="dim"), disabled=True))
|
|
94
|
+
else:
|
|
95
|
+
for s in selectors:
|
|
96
|
+
ol.add_option(list_option_prompt(s["path"], s["enabled"]))
|
|
97
|
+
if highlight is not None and selectors:
|
|
98
|
+
ol.highlighted = min(highlight, len(selectors) - 1)
|
|
99
|
+
|
|
100
|
+
def action_cursor_down(self) -> None:
|
|
101
|
+
self.query_one("#selector-list", OptionList).action_cursor_down()
|
|
102
|
+
|
|
103
|
+
def action_cursor_up(self) -> None:
|
|
104
|
+
self.query_one("#selector-list", OptionList).action_cursor_up()
|
|
105
|
+
|
|
106
|
+
async def action_toggle_item(self) -> None:
|
|
107
|
+
ol = self.query_one("#selector-list", OptionList)
|
|
108
|
+
idx = ol.highlighted
|
|
109
|
+
if idx is not None and idx < len(self._sp.selectors):
|
|
110
|
+
await self._sp.toggle_selector(idx)
|
|
111
|
+
self._refresh_list(idx)
|
|
112
|
+
|
|
113
|
+
async def action_delete(self) -> None:
|
|
114
|
+
ol = self.query_one("#selector-list", OptionList)
|
|
115
|
+
idx = ol.highlighted
|
|
116
|
+
selectors = self._sp.selectors
|
|
117
|
+
if idx is None or idx >= len(selectors):
|
|
118
|
+
return
|
|
119
|
+
self._clipboard = selectors[idx]["path"]
|
|
120
|
+
await self._sp.remove_selector(idx)
|
|
121
|
+
self._refresh_list(idx)
|
|
122
|
+
|
|
123
|
+
def action_yank(self) -> None:
|
|
124
|
+
ol = self.query_one("#selector-list", OptionList)
|
|
125
|
+
idx = ol.highlighted
|
|
126
|
+
selectors = self._sp.selectors
|
|
127
|
+
if idx is None or idx >= len(selectors):
|
|
128
|
+
return
|
|
129
|
+
self._clipboard = selectors[idx]["path"]
|
|
130
|
+
|
|
131
|
+
async def action_paste(self) -> None:
|
|
132
|
+
if self._clipboard is None:
|
|
133
|
+
return
|
|
134
|
+
ol = self.query_one("#selector-list", OptionList)
|
|
135
|
+
idx = ol.highlighted
|
|
136
|
+
target = (idx + 1) if idx is not None else len(self._sp.selectors)
|
|
137
|
+
await self._sp.insert_selector(target, self._clipboard)
|
|
138
|
+
self._clipboard = None
|
|
139
|
+
self._refresh_list(target)
|
|
140
|
+
|
|
141
|
+
def action_add(self) -> None:
|
|
142
|
+
ol = self.query_one("#selector-list", OptionList)
|
|
143
|
+
idx = ol.highlighted
|
|
144
|
+
target = (idx + 1) if idx is not None else len(self._sp.selectors)
|
|
145
|
+
|
|
146
|
+
async def on_dismiss(value: str | None) -> None:
|
|
147
|
+
if not value:
|
|
148
|
+
return
|
|
149
|
+
path = value.strip()
|
|
150
|
+
if not path:
|
|
151
|
+
return
|
|
152
|
+
await self._sp.insert_selector(target, path)
|
|
153
|
+
self._refresh_list(target)
|
|
154
|
+
|
|
155
|
+
self.app.push_screen(
|
|
156
|
+
TextInputScreen("Add selector", placeholder="jq selector..."),
|
|
157
|
+
on_dismiss,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
def action_edit(self) -> None:
|
|
161
|
+
ol = self.query_one("#selector-list", OptionList)
|
|
162
|
+
idx = ol.highlighted
|
|
163
|
+
selectors = self._sp.selectors
|
|
164
|
+
if idx is None or idx >= len(selectors):
|
|
165
|
+
return
|
|
166
|
+
current = selectors[idx]["path"]
|
|
167
|
+
|
|
168
|
+
async def on_dismiss(value: str | None) -> None:
|
|
169
|
+
if not value:
|
|
170
|
+
return
|
|
171
|
+
path = value.strip()
|
|
172
|
+
if not path:
|
|
173
|
+
return
|
|
174
|
+
await self._sp.edit_selector(idx, path)
|
|
175
|
+
self._refresh_list(idx)
|
|
176
|
+
|
|
177
|
+
self.app.push_screen(
|
|
178
|
+
TextInputScreen(
|
|
179
|
+
"Edit selector",
|
|
180
|
+
placeholder="jq selector...",
|
|
181
|
+
initial_value=current,
|
|
182
|
+
),
|
|
183
|
+
on_dismiss,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
def action_maybe_close(self) -> None:
|
|
187
|
+
self.dismiss(True)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
from typing import TypedDict
|
|
2
|
+
|
|
3
|
+
from aioreactive import AsyncSubject
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Selector(TypedDict):
|
|
7
|
+
path: str
|
|
8
|
+
enabled: bool
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SelectorProvider:
|
|
12
|
+
"""Tracks user-defined jq selectors for extracting values from entries."""
|
|
13
|
+
|
|
14
|
+
on_change: AsyncSubject[None]
|
|
15
|
+
|
|
16
|
+
def __init__(self) -> None:
|
|
17
|
+
self._selectors: list[Selector] = []
|
|
18
|
+
self.on_change = AsyncSubject[None]()
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def selectors(self) -> list[Selector]:
|
|
22
|
+
return list(self._selectors)
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def active_selectors(self) -> set[str]:
|
|
26
|
+
return {s["path"] for s in self._selectors if s["enabled"]}
|
|
27
|
+
|
|
28
|
+
def has_selector(self, path: str) -> bool:
|
|
29
|
+
return any(s["path"] == path for s in self._selectors)
|
|
30
|
+
|
|
31
|
+
async def add_selector(self, path: str) -> None:
|
|
32
|
+
self._selectors.append({"path": path, "enabled": True})
|
|
33
|
+
await self.on_change.asend(None)
|
|
34
|
+
|
|
35
|
+
async def insert_selector(self, index: int, path: str) -> None:
|
|
36
|
+
self._selectors.insert(index, {"path": path, "enabled": True})
|
|
37
|
+
await self.on_change.asend(None)
|
|
38
|
+
|
|
39
|
+
async def remove_selector(self, index: int) -> None:
|
|
40
|
+
self._selectors.pop(index)
|
|
41
|
+
await self.on_change.asend(None)
|
|
42
|
+
|
|
43
|
+
async def remove_selector_by_path(self, path: str) -> None:
|
|
44
|
+
for i, s in enumerate(self._selectors):
|
|
45
|
+
if s["path"] == path:
|
|
46
|
+
self._selectors.pop(i)
|
|
47
|
+
await self.on_change.asend(None)
|
|
48
|
+
return
|
|
49
|
+
|
|
50
|
+
async def toggle_selector(self, index: int) -> None:
|
|
51
|
+
self._selectors[index]["enabled"] = not self._selectors[index]["enabled"]
|
|
52
|
+
await self.on_change.asend(None)
|
|
53
|
+
|
|
54
|
+
async def edit_selector(self, index: int, path: str) -> None:
|
|
55
|
+
self._selectors[index]["path"] = path
|
|
56
|
+
await self.on_change.asend(None)
|
|
57
|
+
|
|
58
|
+
async def set_selectors(self, selectors: list[Selector]) -> None:
|
|
59
|
+
self._selectors = list(selectors)
|
|
60
|
+
await self.on_change.asend(None)
|
|
61
|
+
|
|
62
|
+
async def clear_selectors(self) -> None:
|
|
63
|
+
self._selectors.clear()
|
|
64
|
+
await self.on_change.asend(None)
|
jnav/store.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
from aioreactive import AsyncSubject
|
|
4
|
+
|
|
5
|
+
from jnav.parsing import ParsedEntry
|
|
6
|
+
|
|
7
|
+
LogEntry = ParsedEntry
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class IndexedEntry:
|
|
12
|
+
index: int
|
|
13
|
+
entry: LogEntry
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Store:
|
|
17
|
+
entries: list[LogEntry]
|
|
18
|
+
on_append: AsyncSubject[list[IndexedEntry]]
|
|
19
|
+
|
|
20
|
+
def __init__(self) -> None:
|
|
21
|
+
self.entries = []
|
|
22
|
+
self.on_append = AsyncSubject[list[IndexedEntry]]()
|
|
23
|
+
|
|
24
|
+
async def append_entries(self, new_entries: list[LogEntry]) -> None:
|
|
25
|
+
start = len(self.entries)
|
|
26
|
+
self.entries.extend(new_entries)
|
|
27
|
+
indexed = [IndexedEntry(start + i, e) for i, e in enumerate(new_entries)]
|
|
28
|
+
await self.on_append.asend(indexed)
|
|
29
|
+
|
|
30
|
+
def get(self, index: int) -> IndexedEntry:
|
|
31
|
+
return IndexedEntry(index, self.entries[index])
|
|
32
|
+
|
|
33
|
+
def all(self) -> list[IndexedEntry]:
|
|
34
|
+
return [IndexedEntry(i, e) for i, e in enumerate(self.entries)]
|
|
35
|
+
|
|
36
|
+
def __len__(self) -> int:
|
|
37
|
+
return len(self.entries)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
from typing import ClassVar, override
|
|
2
|
+
|
|
3
|
+
from textual import on
|
|
4
|
+
from textual.app import ComposeResult
|
|
5
|
+
from textual.binding import Binding, BindingType
|
|
6
|
+
from textual.screen import ModalScreen
|
|
7
|
+
from textual.widgets import Input
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TextInputScreen(ModalScreen[str | None]):
|
|
11
|
+
DEFAULT_CSS = """
|
|
12
|
+
TextInputScreen {
|
|
13
|
+
align: center middle;
|
|
14
|
+
}
|
|
15
|
+
#text-input {
|
|
16
|
+
width: 50;
|
|
17
|
+
max-width: 90%;
|
|
18
|
+
height: 3;
|
|
19
|
+
border: round $primary;
|
|
20
|
+
background: $background;
|
|
21
|
+
padding: 0 2;
|
|
22
|
+
margin: 0;
|
|
23
|
+
|
|
24
|
+
&:focus {
|
|
25
|
+
background-tint: transparent;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
BINDINGS: ClassVar[list[BindingType]] = [
|
|
31
|
+
Binding("escape", "close", "Close", priority=True),
|
|
32
|
+
Binding("ctrl+c", "close", show=False),
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
title: str = "Search",
|
|
38
|
+
placeholder: str = "search term...",
|
|
39
|
+
initial_value: str = "",
|
|
40
|
+
) -> None:
|
|
41
|
+
super().__init__()
|
|
42
|
+
self._title = title
|
|
43
|
+
self._placeholder = placeholder
|
|
44
|
+
self._initial_value = initial_value
|
|
45
|
+
|
|
46
|
+
@override
|
|
47
|
+
def compose(self) -> ComposeResult:
|
|
48
|
+
yield Input(placeholder=self._placeholder, id="text-input")
|
|
49
|
+
|
|
50
|
+
def on_mount(self) -> None:
|
|
51
|
+
self.query_one("#text-input").border_title = self._title
|
|
52
|
+
inp = self.query_one("#text-input", Input)
|
|
53
|
+
if self._initial_value:
|
|
54
|
+
inp.value = self._initial_value
|
|
55
|
+
inp.focus()
|
|
56
|
+
|
|
57
|
+
@on(Input.Submitted, "#text-input")
|
|
58
|
+
def on_submitted(self, event: Input.Submitted) -> None:
|
|
59
|
+
term = event.value.strip()
|
|
60
|
+
self.dismiss(term if term else None)
|
|
61
|
+
|
|
62
|
+
def action_close(self) -> None:
|
|
63
|
+
self.dismiss(None)
|