sqlide 0.1.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.
- sqlide/__init__.py +3 -0
- sqlide/__main__.py +5 -0
- sqlide/app.py +46 -0
- sqlide/cli.py +64 -0
- sqlide/clipboard.py +65 -0
- sqlide/config/__init__.py +1 -0
- sqlide/config/_toml.py +41 -0
- sqlide/config/connections.py +76 -0
- sqlide/config/keymap.py +25 -0
- sqlide/config/paths.py +48 -0
- sqlide/config/secrets.py +82 -0
- sqlide/config/settings.py +32 -0
- sqlide/consoles.py +100 -0
- sqlide/db/__init__.py +1 -0
- sqlide/db/completion.py +127 -0
- sqlide/db/factory.py +37 -0
- sqlide/db/metadata.py +174 -0
- sqlide/db/result.py +52 -0
- sqlide/db/session.py +339 -0
- sqlide/db/types.py +84 -0
- sqlide/doctor.py +60 -0
- sqlide/drivers/__init__.py +1 -0
- sqlide/drivers/catalog.toml +103 -0
- sqlide/drivers/cli.py +74 -0
- sqlide/drivers/custom.py +69 -0
- sqlide/drivers/loader.py +75 -0
- sqlide/drivers/maven.py +108 -0
- sqlide/drivers/registry.py +117 -0
- sqlide/export/__init__.py +12 -0
- sqlide/export/base.py +86 -0
- sqlide/export/csv_.py +40 -0
- sqlide/export/html.py +44 -0
- sqlide/export/json_.py +47 -0
- sqlide/export/markdown.py +27 -0
- sqlide/export/service.py +34 -0
- sqlide/export/sql_insert.py +37 -0
- sqlide/export/xlsx.py +109 -0
- sqlide/grid/__init__.py +1 -0
- sqlide/grid/copyfmt.py +97 -0
- sqlide/grid/formatting.py +43 -0
- sqlide/grid/model.py +109 -0
- sqlide/history/__init__.py +5 -0
- sqlide/history/store.py +117 -0
- sqlide/jvm/__init__.py +1 -0
- sqlide/jvm/locate.py +98 -0
- sqlide/jvm/runtime.py +44 -0
- sqlide/sql/__init__.py +1 -0
- sqlide/sql/context.py +195 -0
- sqlide/sql/dialects.py +93 -0
- sqlide/sql/format.py +50 -0
- sqlide/sql/keywords.py +143 -0
- sqlide/sql/lexer.py +148 -0
- sqlide/sql/snippets.py +44 -0
- sqlide/sql/splitter.py +325 -0
- sqlide/ui/__init__.py +1 -0
- sqlide/ui/app.tcss +67 -0
- sqlide/ui/commands.py +59 -0
- sqlide/ui/keymap.py +32 -0
- sqlide/ui/screens/__init__.py +0 -0
- sqlide/ui/screens/connection_editor.py +113 -0
- sqlide/ui/screens/dialogs.py +110 -0
- sqlide/ui/screens/driver_manager.py +195 -0
- sqlide/ui/screens/export_dialog.py +158 -0
- sqlide/ui/screens/grid_dialogs.py +92 -0
- sqlide/ui/screens/history.py +130 -0
- sqlide/ui/screens/main.py +383 -0
- sqlide/ui/screens/settings.py +77 -0
- sqlide/ui/widgets/__init__.py +0 -0
- sqlide/ui/widgets/completion_popup.py +81 -0
- sqlide/ui/widgets/connections_list.py +54 -0
- sqlide/ui/widgets/console_export.py +124 -0
- sqlide/ui/widgets/console_tab.py +335 -0
- sqlide/ui/widgets/console_tabs.py +117 -0
- sqlide/ui/widgets/result_grid.py +435 -0
- sqlide/ui/widgets/result_panel.py +73 -0
- sqlide/ui/widgets/result_view.py +50 -0
- sqlide/ui/widgets/schema_tree.py +145 -0
- sqlide/ui/widgets/sql_editor.py +310 -0
- sqlide/ui/widgets/status_bar.py +32 -0
- sqlide/workspace.py +97 -0
- sqlide-0.1.0.dist-info/METADATA +151 -0
- sqlide-0.1.0.dist-info/RECORD +85 -0
- sqlide-0.1.0.dist-info/WHEEL +4 -0
- sqlide-0.1.0.dist-info/entry_points.txt +2 -0
- sqlide-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
"""Result table: sticky header, row numbers, multi-sort, selection, copy, paging."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import bisect
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
from decimal import Decimal
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from rich.segment import Segment
|
|
11
|
+
from rich.style import Style
|
|
12
|
+
from textual import events
|
|
13
|
+
from textual.binding import Binding
|
|
14
|
+
from textual.geometry import Size
|
|
15
|
+
from textual.message import Message
|
|
16
|
+
from textual.scroll_view import ScrollView
|
|
17
|
+
from textual.strip import Strip
|
|
18
|
+
|
|
19
|
+
from sqlide import clipboard
|
|
20
|
+
from sqlide.db.result import Column, DbError, RowSource
|
|
21
|
+
from sqlide.grid import copyfmt
|
|
22
|
+
from sqlide.grid.formatting import NULL_TEXT, format_value, raw_text
|
|
23
|
+
from sqlide.grid.model import GridModel
|
|
24
|
+
from sqlide.ui.screens.grid_dialogs import CopyMenu, ValueViewer, describe_cell
|
|
25
|
+
|
|
26
|
+
MIN_COL, MAX_COL, SAMPLE_ROWS = 4, 60, 200
|
|
27
|
+
LOAD_AHEAD = 30 # start fetching the next page when the cursor is this close to the end
|
|
28
|
+
_NUMERIC = (int, float, Decimal)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ResultGrid(ScrollView, can_focus=True):
|
|
32
|
+
COMPONENT_CLASSES = {
|
|
33
|
+
"result-grid--header",
|
|
34
|
+
"result-grid--gutter",
|
|
35
|
+
"result-grid--cursor",
|
|
36
|
+
"result-grid--selection",
|
|
37
|
+
"result-grid--stripe",
|
|
38
|
+
"result-grid--null",
|
|
39
|
+
"result-grid--sep",
|
|
40
|
+
}
|
|
41
|
+
DEFAULT_CSS = """
|
|
42
|
+
ResultGrid { height: 1fr; }
|
|
43
|
+
ResultGrid > .result-grid--header { background: $primary 35%; text-style: bold; }
|
|
44
|
+
ResultGrid > .result-grid--gutter { color: $text-muted; background: $surface; }
|
|
45
|
+
ResultGrid > .result-grid--cursor { background: $accent; color: $text; text-style: bold; }
|
|
46
|
+
ResultGrid > .result-grid--selection { background: $primary 45%; }
|
|
47
|
+
ResultGrid > .result-grid--stripe { background: $boost; }
|
|
48
|
+
ResultGrid > .result-grid--null { color: $text-muted; text-style: italic; }
|
|
49
|
+
ResultGrid > .result-grid--sep { color: $primary-darken-2; }
|
|
50
|
+
"""
|
|
51
|
+
BINDINGS = [
|
|
52
|
+
Binding("up", "move(-1, 0)", show=False),
|
|
53
|
+
Binding("down", "move(1, 0)", show=False),
|
|
54
|
+
Binding("left", "move(0, -1)", show=False),
|
|
55
|
+
Binding("right", "move(0, 1)", show=False),
|
|
56
|
+
Binding("shift+up", "move(-1, 0, True)", show=False),
|
|
57
|
+
Binding("shift+down", "move(1, 0, True)", show=False),
|
|
58
|
+
Binding("shift+left", "move(0, -1, True)", show=False),
|
|
59
|
+
Binding("shift+right", "move(0, 1, True)", show=False),
|
|
60
|
+
Binding("pageup", "page(-1)", show=False),
|
|
61
|
+
Binding("pagedown", "page(1)", show=False),
|
|
62
|
+
Binding("home", "row_edge(0)", show=False),
|
|
63
|
+
Binding("end", "row_edge(1)", show=False),
|
|
64
|
+
Binding("ctrl+home", "grid_edge(0)", show=False),
|
|
65
|
+
Binding("ctrl+end", "grid_edge(1)", show=False),
|
|
66
|
+
Binding("ctrl+a", "select_all", "Select all", show=False, id="grid.select_all"),
|
|
67
|
+
Binding("s", "sort(False)", "Sort", id="grid.sort"),
|
|
68
|
+
Binding("S", "sort(True)", "Add sort", show=False),
|
|
69
|
+
Binding("ctrl+c", "copy", "Copy", id="grid.copy"),
|
|
70
|
+
Binding("y", "copy_menu", "Copy as…", id="grid.copy_menu"),
|
|
71
|
+
Binding("enter", "view_value", "View value", id="grid.view_value"),
|
|
72
|
+
Binding("slash", "filter", "Filter", id="grid.filter"),
|
|
73
|
+
Binding("l", "load_more", "More rows", show=False, id="grid.load_more"),
|
|
74
|
+
Binding("L", "load_all", "All rows", show=False, id="grid.load_all"),
|
|
75
|
+
Binding("e", "export", "Export", id="grid.export"),
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
class Summary(Message):
|
|
79
|
+
def __init__(self, text: str) -> None:
|
|
80
|
+
super().__init__()
|
|
81
|
+
self.text = text
|
|
82
|
+
|
|
83
|
+
class FilterRequested(Message):
|
|
84
|
+
pass
|
|
85
|
+
|
|
86
|
+
class ExportRequested(Message):
|
|
87
|
+
def __init__(self, grid: ResultGrid) -> None:
|
|
88
|
+
super().__init__()
|
|
89
|
+
self.grid = grid
|
|
90
|
+
|
|
91
|
+
def __init__(
|
|
92
|
+
self,
|
|
93
|
+
columns: Sequence[Column],
|
|
94
|
+
rows: Sequence[tuple[Any, ...]] = (),
|
|
95
|
+
source: RowSource | None = None,
|
|
96
|
+
page_size: int = 500,
|
|
97
|
+
sql: str = "",
|
|
98
|
+
**kw: Any,
|
|
99
|
+
) -> None:
|
|
100
|
+
super().__init__(**kw)
|
|
101
|
+
self.sql = sql # the statement that produced this result (for re-run exports)
|
|
102
|
+
self.model = GridModel(columns, rows)
|
|
103
|
+
self.source = source
|
|
104
|
+
self.page_size = page_size
|
|
105
|
+
self._loading = False
|
|
106
|
+
self._widths: list[int] = []
|
|
107
|
+
self._starts: list[int] = []
|
|
108
|
+
self._gw = 4
|
|
109
|
+
self._anchor = (0, 0)
|
|
110
|
+
self._cursor = (0, 0)
|
|
111
|
+
self._dragging = False
|
|
112
|
+
|
|
113
|
+
# --- state ---
|
|
114
|
+
@property
|
|
115
|
+
def cursor(self) -> tuple[int, int]:
|
|
116
|
+
return self._cursor
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def summary_text(self) -> str:
|
|
120
|
+
m = self.model
|
|
121
|
+
text = f"{m.total_rows} rows"
|
|
122
|
+
if m.filter_text:
|
|
123
|
+
text = f"{len(m)} of {m.total_rows} rows (filtered)"
|
|
124
|
+
if self.source is not None:
|
|
125
|
+
text += " (more…)"
|
|
126
|
+
return text
|
|
127
|
+
|
|
128
|
+
def on_mount(self) -> None:
|
|
129
|
+
self._relayout()
|
|
130
|
+
|
|
131
|
+
# --- layout ---
|
|
132
|
+
def _relayout(self) -> None:
|
|
133
|
+
m = self.model
|
|
134
|
+
sample = m.rows(0, min(len(m), SAMPLE_ROWS) - 1)
|
|
135
|
+
widths = []
|
|
136
|
+
for ci, col in enumerate(m.columns):
|
|
137
|
+
w = max(
|
|
138
|
+
[len(col.name) + 3, *(len(format_value(r[ci])) for r in sample)], default=MIN_COL
|
|
139
|
+
)
|
|
140
|
+
widths.append(max(MIN_COL, min(MAX_COL, w)))
|
|
141
|
+
self._widths = widths
|
|
142
|
+
self._starts = [0]
|
|
143
|
+
for w in widths:
|
|
144
|
+
self._starts.append(self._starts[-1] + w + 1) # +1 separator
|
|
145
|
+
self._gw = max(4, len(str(max(m.total_rows, 1))) + 2)
|
|
146
|
+
self.virtual_size = Size(self._gw + self._starts[-1], len(m) + 1)
|
|
147
|
+
self.refresh()
|
|
148
|
+
|
|
149
|
+
# --- drawing ---
|
|
150
|
+
def render_line(self, y: int) -> Strip:
|
|
151
|
+
width = self.size.width
|
|
152
|
+
scroll_x, scroll_y = self.scroll_offset
|
|
153
|
+
gw = self._gw
|
|
154
|
+
st = self._styles()
|
|
155
|
+
if y == 0:
|
|
156
|
+
gutter = Segment(" " * gw, st["header"])
|
|
157
|
+
data = self._header_segments(st)
|
|
158
|
+
else:
|
|
159
|
+
vr = y - 1 + scroll_y
|
|
160
|
+
if vr >= len(self.model):
|
|
161
|
+
if vr == 0:
|
|
162
|
+
return Strip([Segment(" (no rows)", st["null"])]).extend_cell_length(width)
|
|
163
|
+
return Strip.blank(width, self.rich_style)
|
|
164
|
+
gutter = Segment(str(vr + 1).rjust(gw - 1) + " ", st["gutter"])
|
|
165
|
+
data = self._row_segments(vr, st)
|
|
166
|
+
body = Strip(data).crop(scroll_x, scroll_x + max(0, width - gw))
|
|
167
|
+
return Strip.join([Strip([gutter]), body]).extend_cell_length(width, self.rich_style)
|
|
168
|
+
|
|
169
|
+
def _styles(self) -> dict[str, Style]:
|
|
170
|
+
g = self.get_component_rich_style
|
|
171
|
+
base = self.rich_style
|
|
172
|
+
return {
|
|
173
|
+
"base": base,
|
|
174
|
+
"header": base + g("result-grid--header"),
|
|
175
|
+
"gutter": base + g("result-grid--gutter"),
|
|
176
|
+
"cursor": base + g("result-grid--cursor"),
|
|
177
|
+
"selection": base + g("result-grid--selection"),
|
|
178
|
+
"stripe": base + g("result-grid--stripe"),
|
|
179
|
+
"null": base + g("result-grid--null"),
|
|
180
|
+
"sep": base + g("result-grid--sep"),
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
def _header_segments(self, st: dict[str, Style]) -> list[Segment]:
|
|
184
|
+
segs: list[Segment] = []
|
|
185
|
+
multi = len(self.model.sort) > 1
|
|
186
|
+
for ci, col in enumerate(self.model.columns):
|
|
187
|
+
label = col.name
|
|
188
|
+
if state := self.model.sort_state(ci):
|
|
189
|
+
desc, rank = state
|
|
190
|
+
label += (" ▼" if desc else " ▲") + (str(rank) if multi else "")
|
|
191
|
+
segs.append(Segment(_fit(label, self._widths[ci], False), st["header"]))
|
|
192
|
+
segs.append(Segment("│", st["header"]))
|
|
193
|
+
return segs
|
|
194
|
+
|
|
195
|
+
def _row_segments(self, vr: int, st: dict[str, Style]) -> list[Segment]:
|
|
196
|
+
segs: list[Segment] = []
|
|
197
|
+
r0, c0, r1, c1 = self.selection_rect()
|
|
198
|
+
row = self.model.row(vr)
|
|
199
|
+
base = st["stripe"] if vr % 2 else st["base"]
|
|
200
|
+
for ci, value in enumerate(row):
|
|
201
|
+
text = format_value(value)
|
|
202
|
+
style = st["null"] if value is None and text == NULL_TEXT else base
|
|
203
|
+
if r0 <= vr <= r1 and c0 <= ci <= c1:
|
|
204
|
+
style = st["selection"]
|
|
205
|
+
if (vr, ci) == self._cursor:
|
|
206
|
+
style = st["cursor"]
|
|
207
|
+
right = isinstance(value, _NUMERIC) and not isinstance(value, bool)
|
|
208
|
+
segs.append(Segment(_fit(text, self._widths[ci], right), style))
|
|
209
|
+
segs.append(Segment("│", st["sep"] if vr % 2 == 0 else st["sep"] + st["stripe"]))
|
|
210
|
+
return segs
|
|
211
|
+
|
|
212
|
+
# --- selection ---
|
|
213
|
+
def selection_rect(self) -> tuple[int, int, int, int]:
|
|
214
|
+
(ar, ac), (cr, cc) = self._anchor, self._cursor
|
|
215
|
+
return min(ar, cr), min(ac, cc), max(ar, cr), max(ac, cc)
|
|
216
|
+
|
|
217
|
+
def _set_cursor(self, row: int, col: int, extend: bool = False) -> None:
|
|
218
|
+
n_rows, n_cols = len(self.model), len(self.model.columns)
|
|
219
|
+
if n_rows == 0 or n_cols == 0:
|
|
220
|
+
return
|
|
221
|
+
self._cursor = (max(0, min(row, n_rows - 1)), max(0, min(col, n_cols - 1)))
|
|
222
|
+
if not extend:
|
|
223
|
+
self._anchor = self._cursor
|
|
224
|
+
self._ensure_visible()
|
|
225
|
+
self.refresh()
|
|
226
|
+
self._maybe_load_more()
|
|
227
|
+
|
|
228
|
+
def _ensure_visible(self) -> None:
|
|
229
|
+
row, col = self._cursor
|
|
230
|
+
h = max(1, self.size.height - 1)
|
|
231
|
+
x, y = self.scroll_offset
|
|
232
|
+
if row < y:
|
|
233
|
+
y = row
|
|
234
|
+
elif row >= y + h:
|
|
235
|
+
y = row - h + 1
|
|
236
|
+
data_w = max(1, self.size.width - self._gw)
|
|
237
|
+
left, right = self._starts[col], self._starts[col + 1]
|
|
238
|
+
if left < x:
|
|
239
|
+
x = left
|
|
240
|
+
elif right > x + data_w:
|
|
241
|
+
x = min(right - data_w, left)
|
|
242
|
+
self.scroll_to(x=x, y=y, animate=False)
|
|
243
|
+
|
|
244
|
+
def action_move(self, dr: int, dc: int, extend: bool = False) -> None:
|
|
245
|
+
self._set_cursor(self._cursor[0] + dr, self._cursor[1] + dc, extend)
|
|
246
|
+
|
|
247
|
+
def action_page(self, direction: int) -> None:
|
|
248
|
+
self._set_cursor(
|
|
249
|
+
self._cursor[0] + direction * max(1, self.size.height - 2), self._cursor[1]
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
def action_row_edge(self, end: int) -> None:
|
|
253
|
+
self._set_cursor(self._cursor[0], len(self.model.columns) - 1 if end else 0)
|
|
254
|
+
|
|
255
|
+
def action_grid_edge(self, end: int) -> None:
|
|
256
|
+
"""Ctrl+Home / Ctrl+End: top-left / bottom-right cell."""
|
|
257
|
+
if end:
|
|
258
|
+
self._set_cursor(len(self.model) - 1, len(self.model.columns) - 1)
|
|
259
|
+
else:
|
|
260
|
+
self._set_cursor(0, 0)
|
|
261
|
+
|
|
262
|
+
def action_select_all(self) -> None:
|
|
263
|
+
n, c = len(self.model), len(self.model.columns)
|
|
264
|
+
if n and c:
|
|
265
|
+
self._anchor, self._cursor = (0, 0), (n - 1, c - 1)
|
|
266
|
+
self.refresh()
|
|
267
|
+
|
|
268
|
+
# --- mouse ---
|
|
269
|
+
def _hit(self, x: int, y: int) -> tuple[int, int] | None:
|
|
270
|
+
"""(view_row, col) under a viewport point; row -1 = header, col -1 = row gutter."""
|
|
271
|
+
sx, sy = self.scroll_offset
|
|
272
|
+
col = -1
|
|
273
|
+
if x >= self._gw:
|
|
274
|
+
col = bisect.bisect_right(self._starts, x - self._gw + sx) - 1
|
|
275
|
+
if not 0 <= col < len(self.model.columns):
|
|
276
|
+
return None
|
|
277
|
+
row = -1 if y == 0 else y - 1 + sy
|
|
278
|
+
return (row, col) if row < len(self.model) else None
|
|
279
|
+
|
|
280
|
+
def on_mouse_down(self, event: events.MouseDown) -> None:
|
|
281
|
+
hit = self._hit(event.x, event.y)
|
|
282
|
+
self.focus()
|
|
283
|
+
if hit is None:
|
|
284
|
+
return
|
|
285
|
+
row, col = hit
|
|
286
|
+
if row == -1:
|
|
287
|
+
if col >= 0:
|
|
288
|
+
self._sort(col, add=event.shift)
|
|
289
|
+
return
|
|
290
|
+
if col == -1: # row number: select the whole row
|
|
291
|
+
self._anchor, self._cursor = (row, 0), (row, len(self.model.columns) - 1)
|
|
292
|
+
self.refresh()
|
|
293
|
+
return
|
|
294
|
+
self._set_cursor(row, col, extend=event.shift)
|
|
295
|
+
self._dragging = True
|
|
296
|
+
self.capture_mouse()
|
|
297
|
+
|
|
298
|
+
def on_mouse_move(self, event: events.MouseMove) -> None:
|
|
299
|
+
if self._dragging and (hit := self._hit(event.x, event.y)):
|
|
300
|
+
row, col = hit
|
|
301
|
+
if row >= 0 and col >= 0:
|
|
302
|
+
self._set_cursor(row, col, extend=True)
|
|
303
|
+
|
|
304
|
+
def on_mouse_up(self, _: events.MouseUp) -> None:
|
|
305
|
+
if self._dragging:
|
|
306
|
+
self._dragging = False
|
|
307
|
+
self.release_mouse()
|
|
308
|
+
|
|
309
|
+
# --- sort / filter ---
|
|
310
|
+
def _sort(self, col: int, add: bool = False) -> None:
|
|
311
|
+
self.model.cycle_sort(col, add)
|
|
312
|
+
self._anchor = self._cursor = (
|
|
313
|
+
min(self._cursor[0], max(0, len(self.model) - 1)),
|
|
314
|
+
self._cursor[1],
|
|
315
|
+
)
|
|
316
|
+
self.refresh()
|
|
317
|
+
|
|
318
|
+
def action_sort(self, add: bool = False) -> None:
|
|
319
|
+
self._sort(self._cursor[1], add)
|
|
320
|
+
|
|
321
|
+
def action_filter(self) -> None:
|
|
322
|
+
self.post_message(self.FilterRequested())
|
|
323
|
+
|
|
324
|
+
def set_filter(self, text: str) -> None:
|
|
325
|
+
self.model.set_filter(text)
|
|
326
|
+
self._anchor = self._cursor = (0, self._cursor[1])
|
|
327
|
+
self.scroll_to(0, 0, animate=False)
|
|
328
|
+
self._relayout()
|
|
329
|
+
self.post_message(self.Summary(self.summary_text))
|
|
330
|
+
|
|
331
|
+
# --- paging ---
|
|
332
|
+
def _maybe_load_more(self) -> None:
|
|
333
|
+
if (
|
|
334
|
+
self.source is not None
|
|
335
|
+
and not self._loading
|
|
336
|
+
and self._cursor[0] >= len(self.model) - LOAD_AHEAD
|
|
337
|
+
):
|
|
338
|
+
self.action_load_more()
|
|
339
|
+
|
|
340
|
+
def action_load_more(self) -> None:
|
|
341
|
+
if self.source is not None and not self._loading:
|
|
342
|
+
self._loading = True
|
|
343
|
+
self.run_worker(self._fetch(False), group="grid-fetch", exit_on_error=False)
|
|
344
|
+
|
|
345
|
+
def action_load_all(self) -> None:
|
|
346
|
+
if self.source is not None and not self._loading:
|
|
347
|
+
self._loading = True
|
|
348
|
+
self.run_worker(self._fetch(True), group="grid-fetch", exit_on_error=False)
|
|
349
|
+
|
|
350
|
+
async def _fetch(self, everything: bool) -> None:
|
|
351
|
+
try:
|
|
352
|
+
while self.source is not None:
|
|
353
|
+
rows, done = await self.source.fetch_more(self.page_size)
|
|
354
|
+
self.model.append(rows)
|
|
355
|
+
if done:
|
|
356
|
+
self.source = None
|
|
357
|
+
self._relayout()
|
|
358
|
+
self.post_message(self.Summary(self.summary_text))
|
|
359
|
+
if not everything:
|
|
360
|
+
break
|
|
361
|
+
except DbError as e:
|
|
362
|
+
self.source = None
|
|
363
|
+
self.app.notify(str(e), title="Cannot load more rows", severity="error")
|
|
364
|
+
self.post_message(self.Summary(self.summary_text))
|
|
365
|
+
finally:
|
|
366
|
+
self._loading = False
|
|
367
|
+
|
|
368
|
+
# --- copy / view ---
|
|
369
|
+
def selection_data(self) -> tuple[list[Column], list[tuple[Any, ...]]]:
|
|
370
|
+
"""Columns and rows of the selection rectangle (view order)."""
|
|
371
|
+
r0, c0, r1, c1 = self.selection_rect()
|
|
372
|
+
rows = [row[c0 : c1 + 1] for row in self.model.rows(r0, r1)]
|
|
373
|
+
return self.model.columns[c0 : c1 + 1], rows
|
|
374
|
+
|
|
375
|
+
def _selected(self) -> tuple[list[tuple[Any, ...]], list[str]]:
|
|
376
|
+
cols, rows = self.selection_data()
|
|
377
|
+
return rows, [c.name for c in cols]
|
|
378
|
+
|
|
379
|
+
def render_copy(self, fmt: str) -> str:
|
|
380
|
+
rows, head = self._selected()
|
|
381
|
+
match fmt:
|
|
382
|
+
case "tsv":
|
|
383
|
+
return copyfmt.to_tsv(rows)
|
|
384
|
+
case "tsv_header":
|
|
385
|
+
return copyfmt.to_tsv(rows, head)
|
|
386
|
+
case "csv":
|
|
387
|
+
return copyfmt.to_csv(rows, head)
|
|
388
|
+
case "markdown":
|
|
389
|
+
return copyfmt.to_markdown(rows, head)
|
|
390
|
+
case "json":
|
|
391
|
+
return copyfmt.to_json(rows, head)
|
|
392
|
+
case "insert":
|
|
393
|
+
return copyfmt.to_insert_sql(rows, head)
|
|
394
|
+
case "cell":
|
|
395
|
+
return raw_text(self.model.value(*self._cursor))
|
|
396
|
+
raise ValueError(f"unknown copy format {fmt}")
|
|
397
|
+
|
|
398
|
+
def copy(self, fmt: str = "tsv") -> None:
|
|
399
|
+
if not len(self.model):
|
|
400
|
+
return
|
|
401
|
+
text = self.render_copy(fmt)
|
|
402
|
+
used = clipboard.copy_native(text)
|
|
403
|
+
if used is None:
|
|
404
|
+
self.app.copy_to_clipboard(text)
|
|
405
|
+
rows, head = self._selected()
|
|
406
|
+
self.app.notify(f"Copied {len(rows)}×{len(head)} ({used or 'terminal clipboard'})")
|
|
407
|
+
|
|
408
|
+
def action_export(self) -> None:
|
|
409
|
+
if len(self.model):
|
|
410
|
+
self.post_message(self.ExportRequested(self))
|
|
411
|
+
|
|
412
|
+
def action_copy(self) -> None:
|
|
413
|
+
self.copy("tsv")
|
|
414
|
+
|
|
415
|
+
def action_copy_menu(self) -> None:
|
|
416
|
+
if len(self.model):
|
|
417
|
+
self.app.push_screen(CopyMenu(), lambda fmt: self.copy(fmt) if fmt else None)
|
|
418
|
+
|
|
419
|
+
def action_view_value(self) -> None:
|
|
420
|
+
if not len(self.model):
|
|
421
|
+
return
|
|
422
|
+
row, col = self._cursor
|
|
423
|
+
column = self.model.columns[col]
|
|
424
|
+
value = self.model.value(row, col)
|
|
425
|
+
self.app.push_screen(
|
|
426
|
+
ValueViewer(
|
|
427
|
+
describe_cell(column.name, column.type_name, value), raw_text(value) or NULL_TEXT
|
|
428
|
+
)
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _fit(text: str, width: int, right: bool) -> str:
|
|
433
|
+
if len(text) > width:
|
|
434
|
+
text = text[: width - 1] + "…"
|
|
435
|
+
return text.rjust(width) if right else text.ljust(width)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Tabs under the editor: an Output log plus one tab per result set."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from rich.text import Text
|
|
9
|
+
from textual.app import ComposeResult
|
|
10
|
+
from textual.containers import Vertical
|
|
11
|
+
from textual.widgets import RichLog, TabbedContent, TabPane
|
|
12
|
+
|
|
13
|
+
from sqlide.db.result import Column, RowSource
|
|
14
|
+
from sqlide.ui.widgets.result_grid import ResultGrid
|
|
15
|
+
from sqlide.ui.widgets.result_view import ResultView
|
|
16
|
+
|
|
17
|
+
OUTPUT = "output"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ResultPanel(Vertical):
|
|
21
|
+
def __init__(self, **kw: Any) -> None:
|
|
22
|
+
super().__init__(**kw)
|
|
23
|
+
self._counter = 0
|
|
24
|
+
|
|
25
|
+
def compose(self) -> ComposeResult:
|
|
26
|
+
with TabbedContent(id="tabs"), TabPane("Output", id=OUTPUT):
|
|
27
|
+
yield RichLog(id="log", wrap=True, markup=False, highlight=False)
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def tabs(self) -> TabbedContent:
|
|
31
|
+
return self.query_one("#tabs", TabbedContent)
|
|
32
|
+
|
|
33
|
+
def log_line(self, text: str, style: str = "") -> None:
|
|
34
|
+
self.query_one("#log", RichLog).write(Text(text, style=style))
|
|
35
|
+
|
|
36
|
+
def log_error(self, text: str) -> None:
|
|
37
|
+
self.log_line(text, "bold red")
|
|
38
|
+
|
|
39
|
+
def clear_log(self) -> None:
|
|
40
|
+
self.query_one("#log", RichLog).clear()
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def result_pane_ids(self) -> list[str]:
|
|
44
|
+
return [p.id for p in self.tabs.query(TabPane) if p.id and p.id != OUTPUT]
|
|
45
|
+
|
|
46
|
+
async def reset_results(self) -> None:
|
|
47
|
+
for pane_id in self.result_pane_ids:
|
|
48
|
+
await self.tabs.remove_pane(pane_id)
|
|
49
|
+
self.tabs.active = OUTPUT
|
|
50
|
+
|
|
51
|
+
async def add_result(
|
|
52
|
+
self,
|
|
53
|
+
title: str,
|
|
54
|
+
columns: Sequence[Column],
|
|
55
|
+
rows: Sequence[tuple[Any, ...]],
|
|
56
|
+
source: RowSource | None = None,
|
|
57
|
+
page_size: int = 500,
|
|
58
|
+
sql: str = "",
|
|
59
|
+
) -> ResultGrid:
|
|
60
|
+
self._counter += 1
|
|
61
|
+
grid = ResultGrid(columns, rows, source, page_size, sql)
|
|
62
|
+
await self.tabs.add_pane(TabPane(title, ResultView(grid), id=f"r{self._counter}"))
|
|
63
|
+
return grid
|
|
64
|
+
|
|
65
|
+
def show_first_result(self) -> None:
|
|
66
|
+
ids = self.result_pane_ids
|
|
67
|
+
if ids:
|
|
68
|
+
self.tabs.active = ids[0]
|
|
69
|
+
|
|
70
|
+
def focus_active(self) -> None:
|
|
71
|
+
"""Put the keyboard on the visible result grid (or the tab strip for the log)."""
|
|
72
|
+
grids = self.tabs.get_pane(self.tabs.active).query(ResultGrid)
|
|
73
|
+
(grids.first() if grids else self.tabs).focus()
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""One result tab: optional filter bar above the grid."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from textual import events
|
|
6
|
+
from textual.app import ComposeResult
|
|
7
|
+
from textual.containers import Vertical
|
|
8
|
+
from textual.widgets import Input
|
|
9
|
+
|
|
10
|
+
from sqlide.ui.widgets.result_grid import ResultGrid
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ResultView(Vertical):
|
|
14
|
+
DEFAULT_CSS = """
|
|
15
|
+
ResultView { height: 1fr; }
|
|
16
|
+
ResultView > Input { height: 3; display: none; }
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(self, grid: ResultGrid, **kw) -> None:
|
|
20
|
+
super().__init__(**kw)
|
|
21
|
+
self.grid = grid
|
|
22
|
+
|
|
23
|
+
def compose(self) -> ComposeResult:
|
|
24
|
+
yield Input(placeholder="filter rows… (Esc closes)", id="filter")
|
|
25
|
+
yield self.grid
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def filter_input(self) -> Input:
|
|
29
|
+
return self.query_one("#filter", Input)
|
|
30
|
+
|
|
31
|
+
def on_result_grid_filter_requested(self, _: ResultGrid.FilterRequested) -> None:
|
|
32
|
+
bar = self.filter_input
|
|
33
|
+
bar.display = True
|
|
34
|
+
bar.value = self.grid.model.filter_text
|
|
35
|
+
bar.focus()
|
|
36
|
+
|
|
37
|
+
def on_input_changed(self, event: Input.Changed) -> None:
|
|
38
|
+
event.stop()
|
|
39
|
+
self.grid.set_filter(event.value)
|
|
40
|
+
|
|
41
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
42
|
+
event.stop()
|
|
43
|
+
self.grid.focus() # keep the filter active, return to the table
|
|
44
|
+
|
|
45
|
+
def on_key(self, event: events.Key) -> None:
|
|
46
|
+
if event.key == "escape" and self.filter_input.has_focus:
|
|
47
|
+
event.stop()
|
|
48
|
+
self.filter_input.value = ""
|
|
49
|
+
self.filter_input.display = False
|
|
50
|
+
self.grid.focus()
|