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.
Files changed (85) hide show
  1. sqlide/__init__.py +3 -0
  2. sqlide/__main__.py +5 -0
  3. sqlide/app.py +46 -0
  4. sqlide/cli.py +64 -0
  5. sqlide/clipboard.py +65 -0
  6. sqlide/config/__init__.py +1 -0
  7. sqlide/config/_toml.py +41 -0
  8. sqlide/config/connections.py +76 -0
  9. sqlide/config/keymap.py +25 -0
  10. sqlide/config/paths.py +48 -0
  11. sqlide/config/secrets.py +82 -0
  12. sqlide/config/settings.py +32 -0
  13. sqlide/consoles.py +100 -0
  14. sqlide/db/__init__.py +1 -0
  15. sqlide/db/completion.py +127 -0
  16. sqlide/db/factory.py +37 -0
  17. sqlide/db/metadata.py +174 -0
  18. sqlide/db/result.py +52 -0
  19. sqlide/db/session.py +339 -0
  20. sqlide/db/types.py +84 -0
  21. sqlide/doctor.py +60 -0
  22. sqlide/drivers/__init__.py +1 -0
  23. sqlide/drivers/catalog.toml +103 -0
  24. sqlide/drivers/cli.py +74 -0
  25. sqlide/drivers/custom.py +69 -0
  26. sqlide/drivers/loader.py +75 -0
  27. sqlide/drivers/maven.py +108 -0
  28. sqlide/drivers/registry.py +117 -0
  29. sqlide/export/__init__.py +12 -0
  30. sqlide/export/base.py +86 -0
  31. sqlide/export/csv_.py +40 -0
  32. sqlide/export/html.py +44 -0
  33. sqlide/export/json_.py +47 -0
  34. sqlide/export/markdown.py +27 -0
  35. sqlide/export/service.py +34 -0
  36. sqlide/export/sql_insert.py +37 -0
  37. sqlide/export/xlsx.py +109 -0
  38. sqlide/grid/__init__.py +1 -0
  39. sqlide/grid/copyfmt.py +97 -0
  40. sqlide/grid/formatting.py +43 -0
  41. sqlide/grid/model.py +109 -0
  42. sqlide/history/__init__.py +5 -0
  43. sqlide/history/store.py +117 -0
  44. sqlide/jvm/__init__.py +1 -0
  45. sqlide/jvm/locate.py +98 -0
  46. sqlide/jvm/runtime.py +44 -0
  47. sqlide/sql/__init__.py +1 -0
  48. sqlide/sql/context.py +195 -0
  49. sqlide/sql/dialects.py +93 -0
  50. sqlide/sql/format.py +50 -0
  51. sqlide/sql/keywords.py +143 -0
  52. sqlide/sql/lexer.py +148 -0
  53. sqlide/sql/snippets.py +44 -0
  54. sqlide/sql/splitter.py +325 -0
  55. sqlide/ui/__init__.py +1 -0
  56. sqlide/ui/app.tcss +67 -0
  57. sqlide/ui/commands.py +59 -0
  58. sqlide/ui/keymap.py +32 -0
  59. sqlide/ui/screens/__init__.py +0 -0
  60. sqlide/ui/screens/connection_editor.py +113 -0
  61. sqlide/ui/screens/dialogs.py +110 -0
  62. sqlide/ui/screens/driver_manager.py +195 -0
  63. sqlide/ui/screens/export_dialog.py +158 -0
  64. sqlide/ui/screens/grid_dialogs.py +92 -0
  65. sqlide/ui/screens/history.py +130 -0
  66. sqlide/ui/screens/main.py +383 -0
  67. sqlide/ui/screens/settings.py +77 -0
  68. sqlide/ui/widgets/__init__.py +0 -0
  69. sqlide/ui/widgets/completion_popup.py +81 -0
  70. sqlide/ui/widgets/connections_list.py +54 -0
  71. sqlide/ui/widgets/console_export.py +124 -0
  72. sqlide/ui/widgets/console_tab.py +335 -0
  73. sqlide/ui/widgets/console_tabs.py +117 -0
  74. sqlide/ui/widgets/result_grid.py +435 -0
  75. sqlide/ui/widgets/result_panel.py +73 -0
  76. sqlide/ui/widgets/result_view.py +50 -0
  77. sqlide/ui/widgets/schema_tree.py +145 -0
  78. sqlide/ui/widgets/sql_editor.py +310 -0
  79. sqlide/ui/widgets/status_bar.py +32 -0
  80. sqlide/workspace.py +97 -0
  81. sqlide-0.1.0.dist-info/METADATA +151 -0
  82. sqlide-0.1.0.dist-info/RECORD +85 -0
  83. sqlide-0.1.0.dist-info/WHEEL +4 -0
  84. sqlide-0.1.0.dist-info/entry_points.txt +2 -0
  85. 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()