execsql2 2.13.2__py3-none-any.whl → 2.15.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.
- execsql/gui/base.py +52 -1
- execsql/gui/console.py +86 -9
- execsql/gui/desktop.py +261 -39
- execsql/gui/tui.py +325 -51
- execsql/metacommands/connect.py +5 -1
- execsql/metacommands/dispatch.py +49 -6
- execsql/metacommands/io_export.py +2 -2
- execsql/metacommands/prompt.py +6 -11
- execsql/metacommands/upsert.py +125 -17
- execsql/utils/gui.py +2 -2
- {execsql2-2.13.2.dist-info → execsql2-2.15.0.dist-info}/METADATA +3 -3
- {execsql2-2.13.2.dist-info → execsql2-2.15.0.dist-info}/RECORD +31 -31
- {execsql2-2.13.2.data → execsql2-2.15.0.data}/data/execsql2_extras/README.md +0 -0
- {execsql2-2.13.2.data → execsql2-2.15.0.data}/data/execsql2_extras/config_settings.sqlite +0 -0
- {execsql2-2.13.2.data → execsql2-2.15.0.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
- {execsql2-2.13.2.data → execsql2-2.15.0.data}/data/execsql2_extras/execsql.conf +0 -0
- {execsql2-2.13.2.data → execsql2-2.15.0.data}/data/execsql2_extras/make_config_db.sql +0 -0
- {execsql2-2.13.2.data → execsql2-2.15.0.data}/data/execsql2_extras/md_compare.sql +0 -0
- {execsql2-2.13.2.data → execsql2-2.15.0.data}/data/execsql2_extras/md_glossary.sql +0 -0
- {execsql2-2.13.2.data → execsql2-2.15.0.data}/data/execsql2_extras/md_upsert.sql +0 -0
- {execsql2-2.13.2.data → execsql2-2.15.0.data}/data/execsql2_extras/pg_compare.sql +0 -0
- {execsql2-2.13.2.data → execsql2-2.15.0.data}/data/execsql2_extras/pg_glossary.sql +0 -0
- {execsql2-2.13.2.data → execsql2-2.15.0.data}/data/execsql2_extras/pg_upsert.sql +0 -0
- {execsql2-2.13.2.data → execsql2-2.15.0.data}/data/execsql2_extras/script_template.sql +0 -0
- {execsql2-2.13.2.data → execsql2-2.15.0.data}/data/execsql2_extras/ss_compare.sql +0 -0
- {execsql2-2.13.2.data → execsql2-2.15.0.data}/data/execsql2_extras/ss_glossary.sql +0 -0
- {execsql2-2.13.2.data → execsql2-2.15.0.data}/data/execsql2_extras/ss_upsert.sql +0 -0
- {execsql2-2.13.2.dist-info → execsql2-2.15.0.dist-info}/WHEEL +0 -0
- {execsql2-2.13.2.dist-info → execsql2-2.15.0.dist-info}/entry_points.txt +0 -0
- {execsql2-2.13.2.dist-info → execsql2-2.15.0.dist-info}/licenses/LICENSE.txt +0 -0
- {execsql2-2.13.2.dist-info → execsql2-2.15.0.dist-info}/licenses/NOTICE +0 -0
execsql/gui/tui.py
CHANGED
|
@@ -45,24 +45,34 @@ from textual.widgets import (
|
|
|
45
45
|
Button,
|
|
46
46
|
Checkbox,
|
|
47
47
|
DataTable,
|
|
48
|
+
DirectoryTree,
|
|
48
49
|
Footer,
|
|
49
50
|
Header,
|
|
50
51
|
Input,
|
|
51
52
|
Label,
|
|
52
53
|
ProgressBar,
|
|
54
|
+
RadioButton,
|
|
55
|
+
RadioSet,
|
|
53
56
|
RichLog,
|
|
54
57
|
Select,
|
|
58
|
+
SelectionList,
|
|
55
59
|
Static,
|
|
56
60
|
)
|
|
57
61
|
|
|
58
62
|
__all__ = ["TextualBackend"]
|
|
59
63
|
|
|
64
|
+
from execsql.gui.base import compare_stats as _compare_stats
|
|
60
65
|
|
|
61
66
|
# ---------------------------------------------------------------------------
|
|
62
67
|
# Helpers
|
|
63
68
|
# ---------------------------------------------------------------------------
|
|
64
69
|
|
|
65
70
|
|
|
71
|
+
def _row_count_text(n: int) -> str:
|
|
72
|
+
"""Return a human-readable row count string, e.g. '3 rows' or '1 row'."""
|
|
73
|
+
return f"{n:,} row{'s' if n != 1 else ''}"
|
|
74
|
+
|
|
75
|
+
|
|
66
76
|
def _make_table_widget(table_id: str, headers: list, rows: list) -> DataTable:
|
|
67
77
|
"""Build a DataTable widget populated with data."""
|
|
68
78
|
dt: DataTable = DataTable(id=table_id, zebra_stripes=True, cursor_type="row")
|
|
@@ -72,6 +82,13 @@ def _make_table_widget(table_id: str, headers: list, rows: list) -> DataTable:
|
|
|
72
82
|
return dt
|
|
73
83
|
|
|
74
84
|
|
|
85
|
+
def _help_button(url: str | None) -> Button | None:
|
|
86
|
+
"""Return a Help button if *url* is truthy, otherwise None."""
|
|
87
|
+
if url:
|
|
88
|
+
return Button("Help", id="btn_help", variant="default")
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
|
|
75
92
|
def _button_row(button_list: list) -> list[Button]:
|
|
76
93
|
"""Create Button widgets from a button_list of (label, value, key?) tuples.
|
|
77
94
|
|
|
@@ -139,11 +156,20 @@ class _BaseDialog(ModalScreen):
|
|
|
139
156
|
}
|
|
140
157
|
DataTable {
|
|
141
158
|
max-height: 15;
|
|
159
|
+
margin-bottom: 0;
|
|
160
|
+
}
|
|
161
|
+
.row-count {
|
|
162
|
+
color: $text-muted;
|
|
142
163
|
margin-bottom: 1;
|
|
143
164
|
}
|
|
144
165
|
Input {
|
|
145
166
|
margin-bottom: 1;
|
|
146
167
|
}
|
|
168
|
+
#btn_help {
|
|
169
|
+
dock: right;
|
|
170
|
+
margin: 0 0 1 1;
|
|
171
|
+
min-width: 8;
|
|
172
|
+
}
|
|
147
173
|
"""
|
|
148
174
|
|
|
149
175
|
def __init__(self, args: dict) -> None:
|
|
@@ -167,6 +193,16 @@ class _BaseDialog(ModalScreen):
|
|
|
167
193
|
self._result = {"button": None, "cancelled": True}
|
|
168
194
|
self.dismiss(self._result)
|
|
169
195
|
|
|
196
|
+
@on(Button.Pressed, "#btn_help")
|
|
197
|
+
def _on_help(self, event: Button.Pressed) -> None:
|
|
198
|
+
"""Open the help URL in the system browser without dismissing the dialog."""
|
|
199
|
+
event.stop()
|
|
200
|
+
import webbrowser
|
|
201
|
+
|
|
202
|
+
url = self.args.get("help_url", "")
|
|
203
|
+
if url:
|
|
204
|
+
webbrowser.open(url)
|
|
205
|
+
|
|
170
206
|
|
|
171
207
|
# ---------------------------------------------------------------------------
|
|
172
208
|
# MSG dialog
|
|
@@ -190,7 +226,6 @@ class MsgScreen(_BaseDialog):
|
|
|
190
226
|
with Horizontal(id="buttons"):
|
|
191
227
|
yield Button("Cancel", id="btn_cancel_exit", variant="warning")
|
|
192
228
|
yield Button("Continue", id="btn_close", variant="primary")
|
|
193
|
-
yield Footer()
|
|
194
229
|
|
|
195
230
|
def action_submit(self) -> None:
|
|
196
231
|
"""Continue the dialog (triggered by Enter key)."""
|
|
@@ -225,7 +260,6 @@ class PauseScreen(_BaseDialog):
|
|
|
225
260
|
with Horizontal(id="buttons"):
|
|
226
261
|
yield Button("Cancel", id="btn_cancel_exit", variant="warning")
|
|
227
262
|
yield Button("Continue", id="btn_continue", variant="primary")
|
|
228
|
-
yield Footer()
|
|
229
263
|
|
|
230
264
|
def on_mount(self) -> None:
|
|
231
265
|
countdown = self.args.get("countdown")
|
|
@@ -273,11 +307,15 @@ class DisplayScreen(_BaseDialog):
|
|
|
273
307
|
with Container(id="dialog"):
|
|
274
308
|
if title:
|
|
275
309
|
yield Label(title, id="title")
|
|
310
|
+
help_btn = _help_button(self.args.get("help_url"))
|
|
311
|
+
if help_btn:
|
|
312
|
+
yield help_btn
|
|
276
313
|
if message:
|
|
277
314
|
yield Static(message, id="message")
|
|
278
315
|
if headers and rows:
|
|
279
316
|
with ScrollableContainer(id="table_container"):
|
|
280
317
|
yield _make_table_widget("main_table", headers, rows)
|
|
318
|
+
yield Static(_row_count_text(len(rows)), classes="row-count")
|
|
281
319
|
if textentry:
|
|
282
320
|
yield Input(
|
|
283
321
|
value=initial,
|
|
@@ -287,7 +325,6 @@ class DisplayScreen(_BaseDialog):
|
|
|
287
325
|
)
|
|
288
326
|
with Horizontal(id="buttons"):
|
|
289
327
|
yield from _button_row(button_list)
|
|
290
|
-
yield Footer()
|
|
291
328
|
|
|
292
329
|
def action_submit(self) -> None:
|
|
293
330
|
"""Submit the first primary button value (triggered by Enter key)."""
|
|
@@ -334,7 +371,8 @@ class EntryFormScreen(_BaseDialog):
|
|
|
334
371
|
_BaseDialog.DEFAULT_CSS
|
|
335
372
|
+ """
|
|
336
373
|
.field-row {
|
|
337
|
-
height:
|
|
374
|
+
height: auto;
|
|
375
|
+
min-height: 3;
|
|
338
376
|
margin-bottom: 1;
|
|
339
377
|
}
|
|
340
378
|
.field-label {
|
|
@@ -362,74 +400,189 @@ class EntryFormScreen(_BaseDialog):
|
|
|
362
400
|
with Container(id="dialog"):
|
|
363
401
|
if title:
|
|
364
402
|
yield Label(title, id="title")
|
|
403
|
+
help_btn = _help_button(self.args.get("help_url"))
|
|
404
|
+
if help_btn:
|
|
405
|
+
yield help_btn
|
|
365
406
|
if message:
|
|
366
407
|
yield Static(message, id="message")
|
|
367
408
|
if headers and rows:
|
|
368
409
|
with ScrollableContainer():
|
|
369
410
|
yield _make_table_widget("form_table", headers, rows)
|
|
411
|
+
yield Static(_row_count_text(len(rows)), classes="row-count")
|
|
370
412
|
with ScrollableContainer(id="fields"):
|
|
371
413
|
for spec in specs:
|
|
372
414
|
etype = (spec.entry_type or "text").lower()
|
|
373
415
|
field_id = f"field_{spec.varname}"
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
416
|
+
# Compute the display label (radiobuttons uses semicolon-delimited)
|
|
417
|
+
if etype == "radiobuttons":
|
|
418
|
+
parts = (spec.label or "").split(";")
|
|
419
|
+
field_label = parts[0].strip() if parts else (spec.varname or "")
|
|
420
|
+
else:
|
|
421
|
+
field_label = spec.label or spec.varname or ""
|
|
422
|
+
|
|
423
|
+
if etype == "listbox" and spec.lookup_list:
|
|
424
|
+
# Multi-row widget — render label + SelectionList vertically
|
|
425
|
+
yield Static(field_label, classes="row-count")
|
|
426
|
+
height = spec.default_height or 4
|
|
427
|
+
sl = SelectionList[str](
|
|
428
|
+
*[(str(v), str(v), False) for v in spec.lookup_list if v is not None],
|
|
429
|
+
id=field_id,
|
|
430
|
+
)
|
|
431
|
+
sl.styles.height = height + 2
|
|
432
|
+
yield sl
|
|
433
|
+
self._inputs[spec.varname] = sl
|
|
434
|
+
elif etype == "radiobuttons":
|
|
435
|
+
# Multi-row widget — render label + RadioSet vertically
|
|
436
|
+
yield Static(field_label, classes="row-count")
|
|
437
|
+
buttons = parts[1:] if len(parts) > 1 else ["Option"]
|
|
438
|
+
rs = RadioSet(
|
|
439
|
+
*[RadioButton(str(b).strip()) for b in buttons if b is not None],
|
|
440
|
+
id=field_id,
|
|
441
|
+
)
|
|
442
|
+
yield rs
|
|
443
|
+
self._inputs[spec.varname] = rs
|
|
444
|
+
else:
|
|
445
|
+
# Single-row widgets — render in a Horizontal row
|
|
446
|
+
with Horizontal(classes="field-row"):
|
|
447
|
+
yield Label(field_label, classes="field-label")
|
|
448
|
+
if etype == "checkbox":
|
|
449
|
+
initial = (spec.initial_value or "").lower() in ("true", "1", "yes")
|
|
450
|
+
cb = Checkbox(label="", value=initial, id=field_id)
|
|
451
|
+
yield cb
|
|
452
|
+
self._inputs[spec.varname] = cb
|
|
453
|
+
elif etype in ("dropdown", "select") and spec.lookup_list:
|
|
454
|
+
# Input with placeholder (Select overlay crashes in modal dialogs)
|
|
455
|
+
valid = ", ".join(str(v) for v in spec.lookup_list if v is not None)
|
|
456
|
+
inp = Input(
|
|
457
|
+
value=spec.initial_value or str(spec.lookup_list[0] or ""),
|
|
458
|
+
id=field_id,
|
|
459
|
+
classes="field-input",
|
|
460
|
+
placeholder=f"Choose: {valid}",
|
|
461
|
+
)
|
|
462
|
+
yield inp
|
|
463
|
+
self._inputs[spec.varname] = inp
|
|
464
|
+
elif etype == "textarea":
|
|
465
|
+
inp = Input(
|
|
466
|
+
value=str(spec.initial_value) if spec.initial_value else "",
|
|
467
|
+
id=field_id,
|
|
468
|
+
classes="field-input",
|
|
469
|
+
placeholder="Enter text",
|
|
470
|
+
)
|
|
471
|
+
yield inp
|
|
472
|
+
self._inputs[spec.varname] = inp
|
|
473
|
+
elif etype in ("inputfile", "outputfile"):
|
|
474
|
+
inp = Input(
|
|
475
|
+
value=str(spec.initial_value) if spec.initial_value else "",
|
|
476
|
+
id=field_id,
|
|
477
|
+
classes="field-input",
|
|
478
|
+
placeholder="Enter file path or click Browse",
|
|
479
|
+
)
|
|
480
|
+
yield inp
|
|
481
|
+
yield Button(
|
|
482
|
+
"Browse…",
|
|
483
|
+
id=f"browse_{spec.varname}",
|
|
484
|
+
variant="default",
|
|
485
|
+
)
|
|
486
|
+
self._inputs[spec.varname] = inp
|
|
487
|
+
else:
|
|
488
|
+
inp = Input(
|
|
489
|
+
value=str(spec.initial_value) if spec.initial_value else "",
|
|
490
|
+
id=field_id,
|
|
491
|
+
classes="field-input",
|
|
492
|
+
restrict=spec.validation_key_regex or None,
|
|
493
|
+
)
|
|
494
|
+
yield inp
|
|
495
|
+
self._inputs[spec.varname] = inp
|
|
496
|
+
# File browser panel (hidden by default, shown when Browse is clicked)
|
|
497
|
+
dt = DirectoryTree(".", id="file_tree")
|
|
498
|
+
dt.styles.height = 10
|
|
499
|
+
dt.styles.display = "none"
|
|
500
|
+
yield dt
|
|
395
501
|
with Horizontal(id="buttons"):
|
|
396
502
|
yield Button("Cancel", id="btn_cancel_exit", variant="warning")
|
|
397
503
|
yield Button("OK", id="btn_ok", variant="primary")
|
|
398
|
-
yield Footer()
|
|
399
504
|
|
|
400
505
|
self._specs = specs
|
|
506
|
+
self._browse_target: str | None = None
|
|
401
507
|
|
|
402
|
-
def
|
|
403
|
-
"""
|
|
508
|
+
def _on_browse_pressed(self, event: Button.Pressed) -> None:
|
|
509
|
+
"""Show/hide the file browser when a Browse button is clicked."""
|
|
510
|
+
btn_id = event.button.id or ""
|
|
511
|
+
if not btn_id.startswith("browse_"):
|
|
512
|
+
return
|
|
513
|
+
event.stop()
|
|
514
|
+
varname = btn_id[len("browse_") :]
|
|
515
|
+
tree = self.query_one("#file_tree", DirectoryTree)
|
|
516
|
+
if tree.styles.display == "none":
|
|
517
|
+
self._browse_target = varname
|
|
518
|
+
tree.styles.display = "block"
|
|
519
|
+
tree.focus()
|
|
520
|
+
else:
|
|
521
|
+
tree.styles.display = "none"
|
|
522
|
+
self._browse_target = None
|
|
523
|
+
|
|
524
|
+
def on_directory_tree_file_selected(self, event: DirectoryTree.FileSelected) -> None:
|
|
525
|
+
"""Populate the target Input with the selected file path."""
|
|
526
|
+
if self._browse_target and self._browse_target in self._inputs:
|
|
527
|
+
widget = self._inputs[self._browse_target]
|
|
528
|
+
widget.value = str(event.path)
|
|
529
|
+
tree = self.query_one("#file_tree", DirectoryTree)
|
|
530
|
+
tree.styles.display = "none"
|
|
531
|
+
self._browse_target = None
|
|
532
|
+
|
|
533
|
+
def _collect_values(self) -> None:
|
|
534
|
+
"""Read widget values into the EntrySpec objects."""
|
|
404
535
|
for spec in self._specs:
|
|
405
536
|
widget = self._inputs.get(spec.varname)
|
|
406
537
|
if widget is None:
|
|
407
538
|
continue
|
|
408
539
|
etype = (spec.entry_type or "text").lower()
|
|
409
540
|
if etype == "checkbox":
|
|
410
|
-
spec.value = "
|
|
411
|
-
elif etype
|
|
412
|
-
|
|
541
|
+
spec.value = "1" if widget.value else "0"
|
|
542
|
+
elif etype == "listbox" and isinstance(widget, SelectionList):
|
|
543
|
+
selected = widget.selected
|
|
544
|
+
items = [str(widget.get_option_at_index(i).value) for i in selected]
|
|
545
|
+
spec.value = ",".join(f"'{v.replace(chr(39), chr(39) + chr(39))}'" for v in items)
|
|
546
|
+
elif etype == "radiobuttons" and isinstance(widget, RadioSet):
|
|
547
|
+
spec.value = str(widget.pressed_index + 1) if widget.pressed_index >= 0 else "1"
|
|
413
548
|
else:
|
|
414
549
|
spec.value = widget.value
|
|
550
|
+
|
|
551
|
+
def _validate(self) -> list[str]:
|
|
552
|
+
"""Validate collected values. Returns list of error messages (empty = valid)."""
|
|
553
|
+
import re as _re
|
|
554
|
+
|
|
555
|
+
errors: list[str] = []
|
|
556
|
+
for spec in self._specs:
|
|
557
|
+
val = spec.value or ""
|
|
558
|
+
etype = (spec.entry_type or "text").lower()
|
|
559
|
+
if spec.required and not val and etype != "checkbox":
|
|
560
|
+
errors.append(f"{spec.label or spec.varname}: required")
|
|
561
|
+
if spec.validation_regex and val and not _re.fullmatch(spec.validation_regex, val):
|
|
562
|
+
errors.append(f"{spec.label or spec.varname}: does not match pattern")
|
|
563
|
+
return errors
|
|
564
|
+
|
|
565
|
+
def _submit_form(self) -> None:
|
|
566
|
+
"""Collect, validate, and dismiss if valid."""
|
|
567
|
+
self._collect_values()
|
|
568
|
+
errors = self._validate()
|
|
569
|
+
if errors:
|
|
570
|
+
self.notify("\n".join(errors), title="Validation Error", severity="error")
|
|
571
|
+
return
|
|
415
572
|
self._result = {"button": 1, "return_value": self._specs}
|
|
416
573
|
self.dismiss(self._result)
|
|
417
574
|
|
|
575
|
+
def action_submit(self) -> None:
|
|
576
|
+
"""Submit the form (triggered by Enter key)."""
|
|
577
|
+
self._submit_form()
|
|
578
|
+
|
|
418
579
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
if etype == "checkbox":
|
|
426
|
-
spec.value = "True" if widget.value else "False"
|
|
427
|
-
elif etype in ("dropdown", "select"):
|
|
428
|
-
spec.value = str(widget.value) if widget.value is not None else ""
|
|
429
|
-
else:
|
|
430
|
-
spec.value = widget.value
|
|
431
|
-
self._result = {"button": 1, "return_value": self._specs}
|
|
432
|
-
self.dismiss(self._result)
|
|
580
|
+
btn_id = event.button.id or ""
|
|
581
|
+
if btn_id.startswith("browse_"):
|
|
582
|
+
self._on_browse_pressed(event)
|
|
583
|
+
return
|
|
584
|
+
if btn_id == "btn_ok":
|
|
585
|
+
self._submit_form()
|
|
433
586
|
|
|
434
587
|
|
|
435
588
|
# ---------------------------------------------------------------------------
|
|
@@ -448,14 +601,30 @@ class CompareScreen(_BaseDialog):
|
|
|
448
601
|
DEFAULT_CSS = (
|
|
449
602
|
_BaseDialog.DEFAULT_CSS
|
|
450
603
|
+ """
|
|
451
|
-
#
|
|
604
|
+
#tables_scroll {
|
|
452
605
|
height: 1fr;
|
|
453
|
-
min-height:
|
|
606
|
+
min-height: 8;
|
|
607
|
+
}
|
|
608
|
+
#tables {
|
|
609
|
+
height: auto;
|
|
454
610
|
}
|
|
455
611
|
.compare-table {
|
|
456
612
|
width: 1fr;
|
|
613
|
+
height: auto;
|
|
457
614
|
margin: 0 1;
|
|
458
615
|
}
|
|
616
|
+
.compare-table DataTable {
|
|
617
|
+
height: auto;
|
|
618
|
+
max-height: 10;
|
|
619
|
+
}
|
|
620
|
+
#diff_toolbar {
|
|
621
|
+
height: auto;
|
|
622
|
+
margin-bottom: 1;
|
|
623
|
+
}
|
|
624
|
+
#diff_legend {
|
|
625
|
+
color: $text-muted;
|
|
626
|
+
margin-bottom: 1;
|
|
627
|
+
}
|
|
459
628
|
"""
|
|
460
629
|
)
|
|
461
630
|
|
|
@@ -468,6 +637,9 @@ class CompareScreen(_BaseDialog):
|
|
|
468
637
|
self._kv_to_ridx2: dict = {}
|
|
469
638
|
self._col_keys1: list = []
|
|
470
639
|
self._col_keys2: list = []
|
|
640
|
+
self._diff_on = False
|
|
641
|
+
self._original_cells1: dict = {}
|
|
642
|
+
self._original_cells2: dict = {}
|
|
471
643
|
|
|
472
644
|
def compose(self) -> ComposeResult:
|
|
473
645
|
title = self.args.get("title", "Compare")
|
|
@@ -477,22 +649,44 @@ class CompareScreen(_BaseDialog):
|
|
|
477
649
|
headers2 = self.args.get("headers2", [])
|
|
478
650
|
rows2 = self.args.get("rows2", [])
|
|
479
651
|
button_list = self.args.get("button_list", [("Continue", 1)])
|
|
652
|
+
sidebyside = self.args.get("sidebyside", True)
|
|
653
|
+
tables_container = Horizontal if sidebyside else Vertical
|
|
480
654
|
|
|
481
655
|
with Container(id="dialog"):
|
|
482
656
|
if title:
|
|
483
657
|
yield Label(title, id="title")
|
|
658
|
+
help_btn = _help_button(self.args.get("help_url"))
|
|
659
|
+
if help_btn:
|
|
660
|
+
yield help_btn
|
|
484
661
|
if message:
|
|
485
662
|
yield Static(message, id="message")
|
|
486
|
-
|
|
663
|
+
if self.args.get("keylist"):
|
|
664
|
+
with Horizontal(id="diff_toolbar"):
|
|
665
|
+
yield Button("Highlight Diffs", id="btn_diff_toggle", variant="default")
|
|
666
|
+
yield Static(
|
|
667
|
+
" [on #2d5a2d] Match [/] [on #5a4b00] Changed [/] [on #5a1a1a] Only in one [/]",
|
|
668
|
+
id="diff_legend",
|
|
669
|
+
)
|
|
670
|
+
with ScrollableContainer(id="tables_scroll"), tables_container(id="tables"):
|
|
487
671
|
with Vertical(classes="compare-table"):
|
|
488
672
|
yield Label("Table 1")
|
|
489
673
|
yield _make_table_widget("table1", headers1, rows1)
|
|
674
|
+
yield Static(_row_count_text(len(rows1)), classes="row-count")
|
|
490
675
|
with Vertical(classes="compare-table"):
|
|
491
676
|
yield Label("Table 2")
|
|
492
677
|
yield _make_table_widget("table2", headers2, rows2)
|
|
678
|
+
yield Static(_row_count_text(len(rows2)), classes="row-count")
|
|
679
|
+
summary = _compare_stats(
|
|
680
|
+
headers1,
|
|
681
|
+
rows1,
|
|
682
|
+
headers2,
|
|
683
|
+
rows2,
|
|
684
|
+
[str(k) for k in self.args.get("keylist", [])],
|
|
685
|
+
)
|
|
686
|
+
if summary:
|
|
687
|
+
yield Static(summary, classes="row-count")
|
|
493
688
|
with Horizontal(id="buttons"):
|
|
494
689
|
yield from _button_row(button_list)
|
|
495
|
-
yield Footer()
|
|
496
690
|
|
|
497
691
|
def on_mount(self) -> None:
|
|
498
692
|
keylist = [str(k) for k in self.args.get("keylist", [])]
|
|
@@ -542,6 +736,71 @@ class CompareScreen(_BaseDialog):
|
|
|
542
736
|
finally:
|
|
543
737
|
self._syncing = False
|
|
544
738
|
|
|
739
|
+
@on(Button.Pressed, "#btn_diff_toggle")
|
|
740
|
+
def _on_diff_toggle(self, event: Button.Pressed) -> None:
|
|
741
|
+
"""Toggle row-level diff highlighting in both tables."""
|
|
742
|
+
event.stop()
|
|
743
|
+
from rich.text import Text
|
|
744
|
+
|
|
745
|
+
self._diff_on = not self._diff_on
|
|
746
|
+
t1: DataTable = self.query_one("#table1", DataTable)
|
|
747
|
+
t2: DataTable = self.query_one("#table2", DataTable)
|
|
748
|
+
rows1 = self.args.get("rows1", [])
|
|
749
|
+
rows2 = self.args.get("rows2", [])
|
|
750
|
+
row_keys1 = list(t1.rows.keys())
|
|
751
|
+
row_keys2 = list(t2.rows.keys())
|
|
752
|
+
|
|
753
|
+
if not self._diff_on:
|
|
754
|
+
# Restore original cell values
|
|
755
|
+
for (rk, ck), val in self._original_cells1.items():
|
|
756
|
+
t1.update_cell(rk, ck, val)
|
|
757
|
+
for (rk, ck), val in self._original_cells2.items():
|
|
758
|
+
t2.update_cell(rk, ck, val)
|
|
759
|
+
self._original_cells1.clear()
|
|
760
|
+
self._original_cells2.clear()
|
|
761
|
+
return
|
|
762
|
+
|
|
763
|
+
keys1_set = set(self._kv_to_ridx1.keys())
|
|
764
|
+
keys2_set = set(self._kv_to_ridx2.keys())
|
|
765
|
+
ridx_to_kv1 = {v: k for k, v in self._kv_to_ridx1.items()}
|
|
766
|
+
ridx_to_kv2 = {v: k for k, v in self._kv_to_ridx2.items()}
|
|
767
|
+
row_map1 = {k: rows1[i] for k, i in self._kv_to_ridx1.items()}
|
|
768
|
+
row_map2 = {k: rows2[i] for k, i in self._kv_to_ridx2.items()}
|
|
769
|
+
|
|
770
|
+
# Muted colors suitable for dark terminal themes
|
|
771
|
+
style_match = "on #2d5a2d"
|
|
772
|
+
style_changed = "on #5a4b00"
|
|
773
|
+
style_only = "on #5a1a1a"
|
|
774
|
+
|
|
775
|
+
def _style_table(
|
|
776
|
+
table: DataTable,
|
|
777
|
+
originals: dict,
|
|
778
|
+
row_keys: list,
|
|
779
|
+
col_keys: list,
|
|
780
|
+
data_rows: list,
|
|
781
|
+
ridx_to_kv: dict,
|
|
782
|
+
other_keys_set: set,
|
|
783
|
+
other_row_map: dict,
|
|
784
|
+
) -> None:
|
|
785
|
+
for ridx in range(len(data_rows)):
|
|
786
|
+
kv = ridx_to_kv.get(ridx)
|
|
787
|
+
if kv is None:
|
|
788
|
+
continue
|
|
789
|
+
rk = row_keys[ridx]
|
|
790
|
+
if kv not in other_keys_set:
|
|
791
|
+
style = style_only
|
|
792
|
+
else:
|
|
793
|
+
r_self = [str(v) if v is not None else "" for v in data_rows[ridx]]
|
|
794
|
+
r_other = [str(v) if v is not None else "" for v in other_row_map[kv]]
|
|
795
|
+
style = style_match if r_self == r_other else style_changed
|
|
796
|
+
for ck in col_keys:
|
|
797
|
+
val = table.get_cell(rk, ck)
|
|
798
|
+
originals[(rk, ck)] = val
|
|
799
|
+
table.update_cell(rk, ck, Text(str(val), style=style))
|
|
800
|
+
|
|
801
|
+
_style_table(t1, self._original_cells1, row_keys1, self._col_keys1, rows1, ridx_to_kv1, keys2_set, row_map2)
|
|
802
|
+
_style_table(t2, self._original_cells2, row_keys2, self._col_keys2, rows2, ridx_to_kv2, keys1_set, row_map1)
|
|
803
|
+
|
|
545
804
|
def action_submit(self) -> None:
|
|
546
805
|
"""Submit the first primary button value (triggered by Enter key)."""
|
|
547
806
|
button_list = self.args.get("button_list", [("Continue", 1)])
|
|
@@ -599,6 +858,9 @@ class SelectRowsScreen(_BaseDialog):
|
|
|
599
858
|
with Container(id="dialog"):
|
|
600
859
|
if title:
|
|
601
860
|
yield Label(title, id="title")
|
|
861
|
+
help_btn = _help_button(self.args.get("help_url"))
|
|
862
|
+
if help_btn:
|
|
863
|
+
yield help_btn
|
|
602
864
|
if message:
|
|
603
865
|
yield Static(message, id="message")
|
|
604
866
|
yield Static("Double-click or press Enter on a row to add it to the right table.", id="hint")
|
|
@@ -606,12 +868,13 @@ class SelectRowsScreen(_BaseDialog):
|
|
|
606
868
|
with Vertical(classes="sel-table"):
|
|
607
869
|
yield Label("Source (select rows)")
|
|
608
870
|
yield _make_table_widget("source_table", headers1, rows1)
|
|
871
|
+
yield Static(_row_count_text(len(rows1)), classes="row-count")
|
|
609
872
|
with Vertical(classes="sel-table"):
|
|
610
873
|
yield Label("Destination")
|
|
611
874
|
yield _make_table_widget("dest_table", headers2, rows2)
|
|
875
|
+
yield Static(_row_count_text(len(rows2)), classes="row-count")
|
|
612
876
|
with Horizontal(id="buttons"):
|
|
613
877
|
yield from _button_row(button_list)
|
|
614
|
-
yield Footer()
|
|
615
878
|
|
|
616
879
|
def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
|
|
617
880
|
if event.data_table.id == "source_table":
|
|
@@ -662,11 +925,15 @@ class SelectSubScreen(_BaseDialog):
|
|
|
662
925
|
with Container(id="dialog"):
|
|
663
926
|
if title:
|
|
664
927
|
yield Label(title, id="title")
|
|
928
|
+
help_btn = _help_button(self.args.get("help_url"))
|
|
929
|
+
if help_btn:
|
|
930
|
+
yield help_btn
|
|
665
931
|
if message:
|
|
666
932
|
yield Static(message, id="message")
|
|
667
933
|
yield Static("Click a row to select it.", id="hint")
|
|
668
934
|
with ScrollableContainer():
|
|
669
935
|
yield _make_table_widget("sel_table", headers, rows)
|
|
936
|
+
yield Static(_row_count_text(len(rows)), classes="row-count")
|
|
670
937
|
with Horizontal(id="buttons"):
|
|
671
938
|
yield from _button_row(button_list)
|
|
672
939
|
|
|
@@ -708,17 +975,20 @@ class ActionScreen(_BaseDialog):
|
|
|
708
975
|
with Container(id="dialog"):
|
|
709
976
|
if title:
|
|
710
977
|
yield Label(title, id="title")
|
|
978
|
+
help_btn = _help_button(self.args.get("help_url"))
|
|
979
|
+
if help_btn:
|
|
980
|
+
yield help_btn
|
|
711
981
|
if message:
|
|
712
982
|
yield Static(message, id="message")
|
|
713
983
|
if headers and rows:
|
|
714
984
|
with ScrollableContainer():
|
|
715
985
|
yield _make_table_widget("action_table", headers, rows)
|
|
986
|
+
yield Static(_row_count_text(len(rows)), classes="row-count")
|
|
716
987
|
with Vertical(id="action_buttons"):
|
|
717
988
|
for i, spec in enumerate(button_specs):
|
|
718
989
|
yield Button(f"{spec.label} — {spec.prompt}", id=f"action_{i}", variant="primary")
|
|
719
990
|
if include_continue:
|
|
720
991
|
yield Button("Continue", id="action_continue", variant="default")
|
|
721
|
-
yield Footer()
|
|
722
992
|
|
|
723
993
|
self._button_specs = button_specs
|
|
724
994
|
|
|
@@ -756,6 +1026,7 @@ class MapScreen(_BaseDialog):
|
|
|
756
1026
|
if headers and rows:
|
|
757
1027
|
with ScrollableContainer():
|
|
758
1028
|
yield _make_table_widget("map_table", headers, rows)
|
|
1029
|
+
yield Static(_row_count_text(len(rows)), classes="row-count")
|
|
759
1030
|
with Horizontal(id="buttons"):
|
|
760
1031
|
yield from _button_row(button_list)
|
|
761
1032
|
|
|
@@ -907,6 +1178,9 @@ class ConnectScreen(_BaseDialog):
|
|
|
907
1178
|
message = self.args.get("message", "")
|
|
908
1179
|
with Container(id="dialog"):
|
|
909
1180
|
yield Label("Connect to Database", id="title")
|
|
1181
|
+
help_btn = _help_button(self.args.get("help_url"))
|
|
1182
|
+
if help_btn:
|
|
1183
|
+
yield help_btn
|
|
910
1184
|
if message:
|
|
911
1185
|
yield Static(message, id="message")
|
|
912
1186
|
with Horizontal(classes="field-row"):
|
execsql/metacommands/connect.py
CHANGED
|
@@ -53,6 +53,8 @@ def x_connect_pg(**kwargs: Any) -> None:
|
|
|
53
53
|
mk_new = kwargs["new"]
|
|
54
54
|
mk_new = unquoted2(mk_new).lower() == "new" if mk_new else False
|
|
55
55
|
pw = kwargs["password"]
|
|
56
|
+
if pw:
|
|
57
|
+
pw = unquoted2(pw)
|
|
56
58
|
enc = kwargs["encoding"]
|
|
57
59
|
if enc:
|
|
58
60
|
enc = unquoted2(enc)
|
|
@@ -160,7 +162,7 @@ def x_connect_user_ssvr(**kwargs: Any) -> None:
|
|
|
160
162
|
new_db = SqlServerDatabase(
|
|
161
163
|
server,
|
|
162
164
|
db_name,
|
|
163
|
-
user,
|
|
165
|
+
user_name=user,
|
|
164
166
|
need_passwd=pw is not None,
|
|
165
167
|
port=portno,
|
|
166
168
|
encoding=enc,
|
|
@@ -422,6 +424,8 @@ def x_connect_dsn(**kwargs: Any) -> None:
|
|
|
422
424
|
if need_pwd:
|
|
423
425
|
need_pwd = need_pwd.lower() == "true"
|
|
424
426
|
pw = kwargs["password"]
|
|
427
|
+
if pw:
|
|
428
|
+
pw = unquoted2(pw)
|
|
425
429
|
enc = kwargs["encoding"]
|
|
426
430
|
if enc:
|
|
427
431
|
new_db = DsnDatabase(kwargs["dsn"], kwargs["user"], need_passwd=need_pwd, encoding=enc, password=pw)
|