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.
Files changed (31) hide show
  1. execsql/gui/base.py +52 -1
  2. execsql/gui/console.py +86 -9
  3. execsql/gui/desktop.py +261 -39
  4. execsql/gui/tui.py +325 -51
  5. execsql/metacommands/connect.py +5 -1
  6. execsql/metacommands/dispatch.py +49 -6
  7. execsql/metacommands/io_export.py +2 -2
  8. execsql/metacommands/prompt.py +6 -11
  9. execsql/metacommands/upsert.py +125 -17
  10. execsql/utils/gui.py +2 -2
  11. {execsql2-2.13.2.dist-info → execsql2-2.15.0.dist-info}/METADATA +3 -3
  12. {execsql2-2.13.2.dist-info → execsql2-2.15.0.dist-info}/RECORD +31 -31
  13. {execsql2-2.13.2.data → execsql2-2.15.0.data}/data/execsql2_extras/README.md +0 -0
  14. {execsql2-2.13.2.data → execsql2-2.15.0.data}/data/execsql2_extras/config_settings.sqlite +0 -0
  15. {execsql2-2.13.2.data → execsql2-2.15.0.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
  16. {execsql2-2.13.2.data → execsql2-2.15.0.data}/data/execsql2_extras/execsql.conf +0 -0
  17. {execsql2-2.13.2.data → execsql2-2.15.0.data}/data/execsql2_extras/make_config_db.sql +0 -0
  18. {execsql2-2.13.2.data → execsql2-2.15.0.data}/data/execsql2_extras/md_compare.sql +0 -0
  19. {execsql2-2.13.2.data → execsql2-2.15.0.data}/data/execsql2_extras/md_glossary.sql +0 -0
  20. {execsql2-2.13.2.data → execsql2-2.15.0.data}/data/execsql2_extras/md_upsert.sql +0 -0
  21. {execsql2-2.13.2.data → execsql2-2.15.0.data}/data/execsql2_extras/pg_compare.sql +0 -0
  22. {execsql2-2.13.2.data → execsql2-2.15.0.data}/data/execsql2_extras/pg_glossary.sql +0 -0
  23. {execsql2-2.13.2.data → execsql2-2.15.0.data}/data/execsql2_extras/pg_upsert.sql +0 -0
  24. {execsql2-2.13.2.data → execsql2-2.15.0.data}/data/execsql2_extras/script_template.sql +0 -0
  25. {execsql2-2.13.2.data → execsql2-2.15.0.data}/data/execsql2_extras/ss_compare.sql +0 -0
  26. {execsql2-2.13.2.data → execsql2-2.15.0.data}/data/execsql2_extras/ss_glossary.sql +0 -0
  27. {execsql2-2.13.2.data → execsql2-2.15.0.data}/data/execsql2_extras/ss_upsert.sql +0 -0
  28. {execsql2-2.13.2.dist-info → execsql2-2.15.0.dist-info}/WHEEL +0 -0
  29. {execsql2-2.13.2.dist-info → execsql2-2.15.0.dist-info}/entry_points.txt +0 -0
  30. {execsql2-2.13.2.dist-info → execsql2-2.15.0.dist-info}/licenses/LICENSE.txt +0 -0
  31. {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: 3;
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
- with Horizontal(classes="field-row"):
375
- yield Label(spec.label or spec.varname, classes="field-label")
376
- if etype == "checkbox":
377
- initial = (spec.initial_value or "").lower() in ("true", "1", "yes")
378
- cb = Checkbox(label="", value=initial, id=field_id)
379
- yield cb
380
- self._inputs[spec.varname] = cb
381
- elif etype in ("dropdown", "select") and spec.lookup_list:
382
- options = [(v, v) for v in spec.lookup_list]
383
- initial = spec.initial_value or (spec.lookup_list[0] if spec.lookup_list else "")
384
- sel = Select(options=options, value=initial, id=field_id)
385
- yield sel
386
- self._inputs[spec.varname] = sel
387
- else:
388
- inp = Input(
389
- value=spec.initial_value or "",
390
- id=field_id,
391
- classes="field-input",
392
- )
393
- yield inp
394
- self._inputs[spec.varname] = inp
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 action_submit(self) -> None:
403
- """Submit the form (triggered by Enter key)."""
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 = "True" if widget.value else "False"
411
- elif etype in ("dropdown", "select"):
412
- spec.value = str(widget.value) if widget.value is not None else ""
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
- if event.button.id == "btn_ok":
420
- for spec in self._specs:
421
- widget = self._inputs.get(spec.varname)
422
- if widget is None:
423
- continue
424
- etype = (spec.entry_type or "text").lower()
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
- #tables {
604
+ #tables_scroll {
452
605
  height: 1fr;
453
- min-height: 10;
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
- with Horizontal(id="tables"):
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"):
@@ -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)