openduck-cli 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.
- openduck/__init__.py +42 -0
- openduck/__main__.py +6 -0
- openduck/core/__init__.py +36 -0
- openduck/core/clipboard.py +57 -0
- openduck/core/config.py +108 -0
- openduck/core/error_classifier.py +53 -0
- openduck/core/sql_utils.py +63 -0
- openduck/gui/__init__.py +5 -0
- openduck/gui/app.py +802 -0
- openduck/gui/dialogs/__init__.py +7 -0
- openduck/gui/dialogs/add_connection_dialog.py +77 -0
- openduck/gui/dialogs/export_dialog.py +109 -0
- openduck/gui/dialogs/save_query_dialog.py +41 -0
- openduck/gui/screens/__init__.py +6 -0
- openduck/gui/screens/about.py +84 -0
- openduck/gui/screens/matrix.py +90 -0
- openduck/gui/styles.py +71 -0
- openduck/gui/widgets/__init__.py +17 -0
- openduck/gui/widgets/database_tree.py +106 -0
- openduck/gui/widgets/file_tree.py +14 -0
- openduck/gui/widgets/query_tab.py +63 -0
- openduck/gui/widgets/resize_handle.py +36 -0
- openduck/gui/widgets/results_table.py +54 -0
- openduck/gui/widgets/sql_editor.py +207 -0
- openduck/main.py +25 -0
- openduck_cli-0.1.0.dist-info/METADATA +204 -0
- openduck_cli-0.1.0.dist-info/RECORD +30 -0
- openduck_cli-0.1.0.dist-info/WHEEL +5 -0
- openduck_cli-0.1.0.dist-info/entry_points.txt +3 -0
- openduck_cli-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Modal dialogs for OpenDuck."""
|
|
2
|
+
|
|
3
|
+
from openduck.gui.dialogs.add_connection_dialog import AddConnectionDialog
|
|
4
|
+
from openduck.gui.dialogs.export_dialog import ExportDialog
|
|
5
|
+
from openduck.gui.dialogs.save_query_dialog import SaveQueryDialog
|
|
6
|
+
|
|
7
|
+
__all__ = ["SaveQueryDialog", "ExportDialog", "AddConnectionDialog"]
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Modal dialog for adding database connections (MySQL / MSSQL)."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from textual.app import ComposeResult
|
|
5
|
+
from textual.containers import Horizontal, Vertical
|
|
6
|
+
from textual.screen import ModalScreen
|
|
7
|
+
from textual.widgets import Button, Input, Select, Static
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AddConnectionDialog(ModalScreen):
|
|
11
|
+
"""Modal dialog to add a MySQL or MSSQL connection."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, existing_conn: dict = None):
|
|
14
|
+
super().__init__()
|
|
15
|
+
self.existing_conn = existing_conn
|
|
16
|
+
|
|
17
|
+
def compose(self) -> ComposeResult:
|
|
18
|
+
with Vertical(id="conn-dialog"):
|
|
19
|
+
yield Static("Add Database Connection", id="conn-dialog-title")
|
|
20
|
+
yield Static("Type:")
|
|
21
|
+
yield Select(
|
|
22
|
+
[("MySQL", "mysql"), ("MSSQL", "mssql")],
|
|
23
|
+
prompt="Select type",
|
|
24
|
+
id="conn-type",
|
|
25
|
+
allow_blank=False,
|
|
26
|
+
value="mysql",
|
|
27
|
+
)
|
|
28
|
+
yield Input(placeholder="Display name...", id="conn-display-name")
|
|
29
|
+
yield Input(placeholder="Host (e.g. localhost)", id="conn-host", value="localhost")
|
|
30
|
+
yield Input(placeholder="Port", id="conn-port", value="3306")
|
|
31
|
+
yield Input(placeholder="Username", id="conn-user")
|
|
32
|
+
yield Input(placeholder="Password", id="conn-password", password=True)
|
|
33
|
+
yield Input(placeholder="Database name", id="conn-database")
|
|
34
|
+
with Horizontal():
|
|
35
|
+
yield Button("Connect", variant="primary", id="btn-connect")
|
|
36
|
+
yield Button("Cancel", id="btn-conn-cancel")
|
|
37
|
+
yield Static("", id="conn-status")
|
|
38
|
+
|
|
39
|
+
def on_mount(self):
|
|
40
|
+
self.query_one("#conn-display-name").focus()
|
|
41
|
+
if self.existing_conn:
|
|
42
|
+
self.query_one("#conn-type", Select).value = self.existing_conn["type"]
|
|
43
|
+
self.query_one("#conn-display-name", Input).value = self.existing_conn.get("display_name", "")
|
|
44
|
+
self.query_one("#conn-host", Input).value = self.existing_conn.get("host", "localhost")
|
|
45
|
+
self.query_one("#conn-port", Input).value = str(self.existing_conn.get("port", "3306"))
|
|
46
|
+
self.query_one("#conn-user", Input).value = self.existing_conn.get("user", "")
|
|
47
|
+
self.query_one("#conn-password", Input).value = self.existing_conn.get("password", "")
|
|
48
|
+
self.query_one("#conn-database", Input).value = self.existing_conn.get("database", "")
|
|
49
|
+
|
|
50
|
+
def on_select_changed(self, event: Select.Changed):
|
|
51
|
+
if event.select.id == "conn-type":
|
|
52
|
+
port_input = self.query_one("#conn-port", Input)
|
|
53
|
+
if event.value == "mysql" and port_input.value in ("1433", ""):
|
|
54
|
+
port_input.value = "3306"
|
|
55
|
+
elif event.value == "mssql" and port_input.value in ("3306", ""):
|
|
56
|
+
port_input.value = "1433"
|
|
57
|
+
|
|
58
|
+
def on_button_pressed(self, event: Button.Pressed):
|
|
59
|
+
if event.button.id == "btn-conn-cancel":
|
|
60
|
+
self.dismiss()
|
|
61
|
+
elif event.button.id == "btn-connect":
|
|
62
|
+
conn_info = {
|
|
63
|
+
"id": self.existing_conn["id"] if self.existing_conn else f"conn_{int(datetime.now().timestamp() * 1000)}",
|
|
64
|
+
"display_name": self.query_one("#conn-display-name", Input).value.strip(),
|
|
65
|
+
"type": self.query_one("#conn-type", Select).value,
|
|
66
|
+
"host": self.query_one("#conn-host", Input).value.strip(),
|
|
67
|
+
"port": int(self.query_one("#conn-port", Input).value.strip() or "0"),
|
|
68
|
+
"user": self.query_one("#conn-user", Input).value.strip(),
|
|
69
|
+
"password": self.query_one("#conn-password", Input).value,
|
|
70
|
+
"database": self.query_one("#conn-database", Input).value.strip(),
|
|
71
|
+
}
|
|
72
|
+
if not conn_info["display_name"]:
|
|
73
|
+
conn_info["display_name"] = f"{conn_info['type']}://{conn_info['host']}/{conn_info['database']}"
|
|
74
|
+
if not all([conn_info["host"], conn_info["database"]]):
|
|
75
|
+
self.query_one("#conn-status").update("[red]Host and database are required[/red]")
|
|
76
|
+
return
|
|
77
|
+
self.dismiss(conn_info)
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Modal dialog for exporting query results to CSV or Excel."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import csv
|
|
5
|
+
import logging
|
|
6
|
+
import re
|
|
7
|
+
from typing import TYPE_CHECKING, Tuple
|
|
8
|
+
from textual.app import ComposeResult
|
|
9
|
+
from textual.containers import Horizontal, Vertical
|
|
10
|
+
from textual.screen import ModalScreen
|
|
11
|
+
from textual.widgets import Button, Input, Static, TextArea
|
|
12
|
+
|
|
13
|
+
from openduck.core.config import CWD
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from openduck.gui.widgets.query_tab import QueryTab
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ExportDialog(ModalScreen):
|
|
22
|
+
"""Modal dialog to export query results into CSV or XLSX files."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, tab: 'QueryTab', export_type: str):
|
|
25
|
+
super().__init__()
|
|
26
|
+
self.tab = tab
|
|
27
|
+
self.export_type = export_type
|
|
28
|
+
|
|
29
|
+
def compose(self) -> ComposeResult:
|
|
30
|
+
with Vertical(id="export-modal"):
|
|
31
|
+
yield Static(f"Export {self.export_type.upper()}", id="export-modal-title")
|
|
32
|
+
yield Input(placeholder="Enter filename...", id="export-filename")
|
|
33
|
+
yield Static("Current query:", id="current-query-label")
|
|
34
|
+
yield Static(
|
|
35
|
+
self.tab.query_one(TextArea).text[:100]
|
|
36
|
+
+ ("..." if len(self.tab.query_one(TextArea).text) > 100 else ""),
|
|
37
|
+
id="current-query-preview",
|
|
38
|
+
)
|
|
39
|
+
with Horizontal():
|
|
40
|
+
yield Button("Export", variant="primary", id="btn-export")
|
|
41
|
+
yield Button("Cancel", id="btn-cancel")
|
|
42
|
+
|
|
43
|
+
def on_mount(self):
|
|
44
|
+
# Pre-populate filename based on query content
|
|
45
|
+
query_text = self.tab.query_one(TextArea).text.strip()
|
|
46
|
+
table_match = re.search(r'FROM\s+(\w+)', query_text, re.IGNORECASE)
|
|
47
|
+
base_name = table_match.group(1) if table_match else "query_result"
|
|
48
|
+
base_name = re.sub(r'[^\w\-_]', '_', base_name)
|
|
49
|
+
extension = ".csv" if self.export_type == "csv" else ".xlsx"
|
|
50
|
+
default_filename = f"{base_name}{extension}"
|
|
51
|
+
|
|
52
|
+
self.query_one("#export-filename").value = default_filename
|
|
53
|
+
self.query_one("#export-filename").focus()
|
|
54
|
+
|
|
55
|
+
def export_to_csv(self, filename: str) -> Tuple[bool, str]:
|
|
56
|
+
"""Export table data to CSV file."""
|
|
57
|
+
try:
|
|
58
|
+
filepath = CWD / filename
|
|
59
|
+
with open(filepath, 'w', newline='', encoding='utf-8') as csvfile:
|
|
60
|
+
writer = csv.writer(csvfile)
|
|
61
|
+
writer.writerow(self.tab.column_names)
|
|
62
|
+
for row in self.tab.full_data:
|
|
63
|
+
writer.writerow(row)
|
|
64
|
+
return True, ""
|
|
65
|
+
except Exception as e:
|
|
66
|
+
logger.error(f"Failed to export CSV: {e}")
|
|
67
|
+
return False, str(e)
|
|
68
|
+
|
|
69
|
+
def export_to_excel(self, filename: str) -> Tuple[bool, str]:
|
|
70
|
+
"""Export table data to Excel file."""
|
|
71
|
+
try:
|
|
72
|
+
import pandas as pd
|
|
73
|
+
except ImportError:
|
|
74
|
+
return False, "pandas is not installed (pip install pandas openpyxl)"
|
|
75
|
+
try:
|
|
76
|
+
import openpyxl # noqa: F401
|
|
77
|
+
except ImportError:
|
|
78
|
+
return False, "openpyxl is not installed (pip install openpyxl)"
|
|
79
|
+
try:
|
|
80
|
+
filepath = CWD / filename
|
|
81
|
+
df = pd.DataFrame(self.tab.full_data, columns=self.tab.column_names)
|
|
82
|
+
df.to_excel(filepath, index=False)
|
|
83
|
+
return True, ""
|
|
84
|
+
except Exception as e:
|
|
85
|
+
logger.error(f"Failed to export Excel: {e}")
|
|
86
|
+
return False, str(e)
|
|
87
|
+
|
|
88
|
+
async def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
89
|
+
if event.button.id == "btn-cancel":
|
|
90
|
+
self.dismiss()
|
|
91
|
+
elif event.button.id == "btn-export":
|
|
92
|
+
filename = self.query_one("#export-filename", Input).value.strip()
|
|
93
|
+
if not filename:
|
|
94
|
+
self.query_one("#export-filename").value = ""
|
|
95
|
+
self.query_one("#export-filename").focus()
|
|
96
|
+
return
|
|
97
|
+
|
|
98
|
+
event.button.disabled = True
|
|
99
|
+
if self.export_type == "csv":
|
|
100
|
+
success, error_msg = await asyncio.to_thread(self.export_to_csv, filename)
|
|
101
|
+
else:
|
|
102
|
+
success, error_msg = await asyncio.to_thread(self.export_to_excel, filename)
|
|
103
|
+
|
|
104
|
+
event.button.disabled = False
|
|
105
|
+
if success:
|
|
106
|
+
self.app.notify(f"Geëxporteerd naar {filename}", timeout=2.0)
|
|
107
|
+
self.dismiss({"exported": True, "filename": filename})
|
|
108
|
+
else:
|
|
109
|
+
self.app.notify(f"Export mislukt: {error_msg}", severity="error", timeout=4.0)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Modal dialog for saving named queries."""
|
|
2
|
+
|
|
3
|
+
from textual.app import ComposeResult
|
|
4
|
+
from textual.containers import Horizontal, Vertical
|
|
5
|
+
from textual.screen import ModalScreen
|
|
6
|
+
from textual.widgets import Button, Input, Static
|
|
7
|
+
|
|
8
|
+
from openduck.core.config import save_query
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SaveQueryDialog(ModalScreen):
|
|
12
|
+
"""Modal dialog to save current query text with a custom name."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, query_text: str):
|
|
15
|
+
super().__init__()
|
|
16
|
+
self.query_text = query_text
|
|
17
|
+
|
|
18
|
+
def compose(self) -> ComposeResult:
|
|
19
|
+
with Vertical(id="save-dialog"):
|
|
20
|
+
yield Static("Save Query", id="dialog-title")
|
|
21
|
+
yield Input(placeholder="Enter query name...", id="query-name")
|
|
22
|
+
yield Static("Query:", id="query-label")
|
|
23
|
+
yield Static(self.query_text[:100] + ("..." if len(self.query_text) > 100 else ""), id="query-preview")
|
|
24
|
+
with Horizontal():
|
|
25
|
+
yield Button("Save", variant="primary", id="btn-save")
|
|
26
|
+
yield Button("Cancel", id="btn-cancel")
|
|
27
|
+
|
|
28
|
+
def on_mount(self):
|
|
29
|
+
self.query_one("#query-name").focus()
|
|
30
|
+
|
|
31
|
+
def on_button_pressed(self, event: Button.Pressed):
|
|
32
|
+
if event.button.id == "btn-cancel":
|
|
33
|
+
self.dismiss()
|
|
34
|
+
elif event.button.id == "btn-save":
|
|
35
|
+
name = self.query_one("#query-name", Input).value
|
|
36
|
+
if name.strip():
|
|
37
|
+
save_query(name, self.query_text)
|
|
38
|
+
self.dismiss({"saved": True, "name": name})
|
|
39
|
+
else:
|
|
40
|
+
self.query_one("#query-name").value = ""
|
|
41
|
+
self.query_one("#query-name").focus()
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""About OpenDuck modal dialog screen."""
|
|
2
|
+
|
|
3
|
+
from rich.text import Text
|
|
4
|
+
from textual.app import ComposeResult
|
|
5
|
+
from textual.containers import Middle, Vertical
|
|
6
|
+
from textual.events import Click
|
|
7
|
+
from textual.screen import ModalScreen
|
|
8
|
+
from textual.widgets import Static
|
|
9
|
+
|
|
10
|
+
from openduck.gui.screens.matrix import MatrixScreen
|
|
11
|
+
from openduck.gui.styles import ABOUT_CSS
|
|
12
|
+
|
|
13
|
+
LOGO_RAW = (
|
|
14
|
+
" ___ _ _ \n"
|
|
15
|
+
" / _ \\ _ __ ___ _ __ __| |_ _ ___| | __\n"
|
|
16
|
+
" | | | | '_ \\ / _ \\ '_ \\ / _` | | | |/ __| |/ /\n"
|
|
17
|
+
" | |_| | |_) | __/ | | | (_| | |_| | (__| < \n"
|
|
18
|
+
" \\___/| .__/ \\___|_| |_|\\__,_|\\__,_|\\___|_|\\_\\\n"
|
|
19
|
+
" |_| "
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
LOLCAT_COLORS = [
|
|
23
|
+
"#ff0000", "#ff4400", "#ff8800", "#ffcc00", "#ffff00", "#88ff00",
|
|
24
|
+
"#00ff00", "#00ff88", "#00ffff", "#0088ff", "#0000ff", "#4400ff",
|
|
25
|
+
"#8800ff", "#cc00ff", "#ff00ff", "#ff0088",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def lolcat(text: str) -> Text:
|
|
30
|
+
"""Apply rainbow colors to text, Rich markup style."""
|
|
31
|
+
rich_text = Text(text)
|
|
32
|
+
lines = text.split("\n")
|
|
33
|
+
pos = 0
|
|
34
|
+
for row, line in enumerate(lines):
|
|
35
|
+
for col, ch in enumerate(line):
|
|
36
|
+
if ch != " ":
|
|
37
|
+
color = LOLCAT_COLORS[(col + row * 3) % len(LOLCAT_COLORS)]
|
|
38
|
+
rich_text.stylize(color, pos, pos + 1)
|
|
39
|
+
pos += 1
|
|
40
|
+
pos += 1 # newline
|
|
41
|
+
return rich_text
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
DUCK_ART = r""" (o. H
|
|
45
|
+
_o) | . : (o]H
|
|
46
|
+
\\\__/ \\\_| : :. \\\_\ H
|
|
47
|
+
<____).....<_____).:.::.......<_____).H"""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class AboutScreen(ModalScreen):
|
|
51
|
+
"""About dialog screen with ASCII duck and Easter egg link."""
|
|
52
|
+
|
|
53
|
+
DEFAULT_CSS = ABOUT_CSS
|
|
54
|
+
|
|
55
|
+
def compose(self) -> ComposeResult:
|
|
56
|
+
with Middle():
|
|
57
|
+
with Vertical(id="about-inner"):
|
|
58
|
+
yield Static(lolcat(LOGO_RAW), id="about-title")
|
|
59
|
+
yield Static("────────────────────────────────────────────────────")
|
|
60
|
+
yield Static("[b]GitHub:[/b] github.com/bertatron/duckcli")
|
|
61
|
+
yield Static("[b]Author:[/b] Bertatron")
|
|
62
|
+
yield Static("[b]License:[/b] MIT")
|
|
63
|
+
yield Static("\nKISS - Metadata & Async enabled.")
|
|
64
|
+
yield Static("Don't click here", id="easter-egg")
|
|
65
|
+
yield Static(DUCK_ART, id="duck-art")
|
|
66
|
+
yield Static("\n[Press any key to close]", id="about-footer")
|
|
67
|
+
|
|
68
|
+
def on_key(self) -> None:
|
|
69
|
+
self.dismiss()
|
|
70
|
+
|
|
71
|
+
def on_click(self, event: Click) -> None:
|
|
72
|
+
if hasattr(event, 'widget') and event.widget and event.widget.id == "easter-egg":
|
|
73
|
+
self.dismiss()
|
|
74
|
+
self.app.push_screen(MatrixScreen())
|
|
75
|
+
else:
|
|
76
|
+
try:
|
|
77
|
+
widget, _ = self.screen.get_widget_at(event.screen_x, event.screen_y)
|
|
78
|
+
if widget.id == "easter-egg":
|
|
79
|
+
self.dismiss()
|
|
80
|
+
self.app.push_screen(MatrixScreen())
|
|
81
|
+
return
|
|
82
|
+
except Exception:
|
|
83
|
+
pass
|
|
84
|
+
self.dismiss()
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Matrix rain screensaver screen (Easter egg)."""
|
|
2
|
+
|
|
3
|
+
import random
|
|
4
|
+
from textual.app import ComposeResult
|
|
5
|
+
from textual.screen import ModalScreen
|
|
6
|
+
from textual.widgets import Static
|
|
7
|
+
|
|
8
|
+
from openduck.gui.styles import MATRIX_CSS
|
|
9
|
+
|
|
10
|
+
MATRIX_CHARS = "アイウエオカキクケコサシスセソタチツテトナニヌネノ0123456789"
|
|
11
|
+
# ANSI 256: 82=#87ff00, 46=#00ff00, 40=#00d700, 34=#00af00, 28=#008700, 22=#005f00
|
|
12
|
+
MATRIX_SHADES = ["#87ff00", "#00ff00", "#00d700", "#00af00", "#008700", "#005f00"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class MatrixScreen(ModalScreen):
|
|
16
|
+
"""Matrix code rain modal screensaver."""
|
|
17
|
+
|
|
18
|
+
DEFAULT_CSS = MATRIX_CSS
|
|
19
|
+
|
|
20
|
+
def __init__(self):
|
|
21
|
+
super().__init__()
|
|
22
|
+
self._drops = []
|
|
23
|
+
self._grid = []
|
|
24
|
+
self._colors = []
|
|
25
|
+
self._timer = None
|
|
26
|
+
|
|
27
|
+
def compose(self) -> ComposeResult:
|
|
28
|
+
yield Static("", id="matrix-canvas")
|
|
29
|
+
|
|
30
|
+
def on_mount(self) -> None:
|
|
31
|
+
cols = self.screen.size.width
|
|
32
|
+
rows = self.screen.size.height
|
|
33
|
+
self._drops = [random.randint(0, rows) for _ in range(cols)]
|
|
34
|
+
# Persistent grid - characters stay until overwritten
|
|
35
|
+
self._grid = [[" "] * cols for _ in range(rows)]
|
|
36
|
+
self._colors = [["#005f00"] * cols for _ in range(rows)]
|
|
37
|
+
self._timer = self.set_interval(0.03, self._tick)
|
|
38
|
+
|
|
39
|
+
def _tick(self) -> None:
|
|
40
|
+
cols = self.screen.size.width
|
|
41
|
+
rows = self.screen.size.height
|
|
42
|
+
# Resize grids if terminal changed
|
|
43
|
+
while len(self._drops) < cols:
|
|
44
|
+
self._drops.append(0)
|
|
45
|
+
self._drops = self._drops[:cols]
|
|
46
|
+
while len(self._grid) < rows:
|
|
47
|
+
self._grid.append([" "] * cols)
|
|
48
|
+
self._colors.append(["#005f00"] * cols)
|
|
49
|
+
self._grid = self._grid[:rows]
|
|
50
|
+
self._colors = self._colors[:rows]
|
|
51
|
+
for r in range(rows):
|
|
52
|
+
while len(self._grid[r]) < cols:
|
|
53
|
+
self._grid[r].append(" ")
|
|
54
|
+
self._colors[r].append("#005f00")
|
|
55
|
+
self._grid[r] = self._grid[r][:cols]
|
|
56
|
+
self._colors[r] = self._colors[r][:cols]
|
|
57
|
+
# Draw drops - characters persist on the grid like the original
|
|
58
|
+
for i in range(cols):
|
|
59
|
+
if random.random() > 0.97:
|
|
60
|
+
self._drops[i] = 0
|
|
61
|
+
for j, shade in enumerate(MATRIX_SHADES):
|
|
62
|
+
y = self._drops[i] - j
|
|
63
|
+
if 0 <= y < rows:
|
|
64
|
+
self._grid[y][i] = random.choice(MATRIX_CHARS)
|
|
65
|
+
self._colors[y][i] = shade
|
|
66
|
+
self._drops[i] += 1
|
|
67
|
+
if self._drops[i] >= rows:
|
|
68
|
+
self._drops[i] = 0
|
|
69
|
+
# Render
|
|
70
|
+
lines = []
|
|
71
|
+
for r in range(rows):
|
|
72
|
+
parts = []
|
|
73
|
+
for c in range(cols):
|
|
74
|
+
ch = self._grid[r][c]
|
|
75
|
+
if ch != " ":
|
|
76
|
+
parts.append(f"[{self._colors[r][c]}]{ch}[/]")
|
|
77
|
+
else:
|
|
78
|
+
parts.append(" ")
|
|
79
|
+
lines.append("".join(parts))
|
|
80
|
+
self.query_one("#matrix-canvas").update("\n".join(lines))
|
|
81
|
+
|
|
82
|
+
def on_key(self) -> None:
|
|
83
|
+
if self._timer:
|
|
84
|
+
self._timer.stop()
|
|
85
|
+
self.dismiss()
|
|
86
|
+
|
|
87
|
+
def on_click(self) -> None:
|
|
88
|
+
if self._timer:
|
|
89
|
+
self._timer.stop()
|
|
90
|
+
self.dismiss()
|
openduck/gui/styles.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Textual CSS styles for OpenDuck UI components."""
|
|
2
|
+
|
|
3
|
+
APP_CSS = """
|
|
4
|
+
#sidebar { width: 30; height: 100%; background: $surface; }
|
|
5
|
+
#files { height: 2fr; min-height: 8; }
|
|
6
|
+
#db-tree { height: 1fr; min-height: 8; }
|
|
7
|
+
#db-tree-inner { height: 1fr; }
|
|
8
|
+
#saved-queries-tree { height: 1fr; min-height: 5; }
|
|
9
|
+
#history-tree { height: 1fr; min-height: 5; }
|
|
10
|
+
#main-content { width: 1fr; }
|
|
11
|
+
TabbedContent { width: 100%; height: 1fr; }
|
|
12
|
+
SqlTextArea, TextArea { height: 35%; border-bottom: tall $primary; }
|
|
13
|
+
ResultsTable, DataTable { height: 1fr; }
|
|
14
|
+
#export-sidebar { width: 14; margin-left: 1; background: $panel; border: round $primary; padding: 0; overflow-y: auto; }
|
|
15
|
+
.export-btn { margin: 0; padding: 0; height: 3; width: 100%; }
|
|
16
|
+
#metadata-container {
|
|
17
|
+
height: 1;
|
|
18
|
+
background: $primary;
|
|
19
|
+
color: $text;
|
|
20
|
+
layout: horizontal;
|
|
21
|
+
}
|
|
22
|
+
#metadata-bar {
|
|
23
|
+
width: 1fr;
|
|
24
|
+
padding: 0 1;
|
|
25
|
+
}
|
|
26
|
+
#memory-bar {
|
|
27
|
+
width: auto;
|
|
28
|
+
padding: 0 1;
|
|
29
|
+
text-align: right;
|
|
30
|
+
}
|
|
31
|
+
#menu { padding: 1 2; background: $surface; border: tall $primary; width: 50; height: auto; }
|
|
32
|
+
#menu-title { text-style: bold; margin-bottom: 1; }
|
|
33
|
+
#filter-input { margin-bottom: 1; }
|
|
34
|
+
Button { margin: 0 1; min-width: 10; }
|
|
35
|
+
AboutScreen { align: center middle; background: rgba(0, 0, 0, 0.7); }
|
|
36
|
+
#about-inner { width: 64; height: auto; background: $surface; border: tall $primary; padding: 2 4; }
|
|
37
|
+
#about-title { text-align: center; color: $accent; }
|
|
38
|
+
#about-footer { text-align: center; color: $text-disabled; }
|
|
39
|
+
#save-dialog { width: 50; height: auto; background: $surface; border: tall $primary; padding: 2; }
|
|
40
|
+
#dialog-title { text-style: bold; margin-bottom: 1; text-align: center; }
|
|
41
|
+
#query-preview { height: 3; border: solid $primary; padding: 1; }
|
|
42
|
+
#export-modal { width: 50; height: auto; background: $surface; border: tall $primary; padding: 2; }
|
|
43
|
+
#export-modal-title { text-style: bold; margin-bottom: 1; text-align: center; }
|
|
44
|
+
#export-filename { margin-bottom: 1; }
|
|
45
|
+
#db-search-input { margin-bottom: 1; }
|
|
46
|
+
#conn-dialog { width: 60; height: auto; background: $surface; border: tall $primary; padding: 2; }
|
|
47
|
+
#conn-dialog-title { text-style: bold; margin-bottom: 1; text-align: center; }
|
|
48
|
+
#conn-status { height: 1; margin-top: 1; }
|
|
49
|
+
AddConnectionDialog { align: center middle; background: rgba(0, 0, 0, 0.7); }
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
MATRIX_CSS = """
|
|
53
|
+
MatrixScreen { background: black; }
|
|
54
|
+
#matrix-canvas { width: 100%; height: 100%; background: black; }
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
ABOUT_CSS = """
|
|
58
|
+
#easter-egg { color: $text-disabled; }
|
|
59
|
+
#duck-art { color: $accent; }
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
RESIZE_CSS = """
|
|
63
|
+
ResizeHandle {
|
|
64
|
+
width: 1;
|
|
65
|
+
height: 100%;
|
|
66
|
+
background: $primary;
|
|
67
|
+
}
|
|
68
|
+
ResizeHandle:hover {
|
|
69
|
+
background: $accent;
|
|
70
|
+
}
|
|
71
|
+
"""
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""UI widgets for OpenDuck."""
|
|
2
|
+
|
|
3
|
+
from openduck.gui.widgets.database_tree import DatabaseTree
|
|
4
|
+
from openduck.gui.widgets.file_tree import DuckTree
|
|
5
|
+
from openduck.gui.widgets.query_tab import QueryTab
|
|
6
|
+
from openduck.gui.widgets.resize_handle import ResizeHandle
|
|
7
|
+
from openduck.gui.widgets.results_table import ResultsTable
|
|
8
|
+
from openduck.gui.widgets.sql_editor import SqlTextArea
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"DatabaseTree",
|
|
12
|
+
"DuckTree",
|
|
13
|
+
"QueryTab",
|
|
14
|
+
"ResizeHandle",
|
|
15
|
+
"ResultsTable",
|
|
16
|
+
"SqlTextArea",
|
|
17
|
+
]
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Database connections explorer tree with table search."""
|
|
2
|
+
|
|
3
|
+
from textual.app import ComposeResult
|
|
4
|
+
from textual.containers import Vertical
|
|
5
|
+
from textual.widgets import Input, Tree
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class DatabaseTree(Vertical):
|
|
9
|
+
"""Tree widget showing database connections and their tables with search functionality."""
|
|
10
|
+
|
|
11
|
+
def __init__(self):
|
|
12
|
+
super().__init__(id="db-tree")
|
|
13
|
+
self.connections_data = {}
|
|
14
|
+
self.original_tree = Tree("Databases", id="db-tree-inner")
|
|
15
|
+
self.search_input = Input(placeholder="Search tables...", id="db-search-input")
|
|
16
|
+
|
|
17
|
+
def compose(self) -> ComposeResult:
|
|
18
|
+
yield self.search_input
|
|
19
|
+
yield self.original_tree
|
|
20
|
+
|
|
21
|
+
def on_mount(self):
|
|
22
|
+
self.original_tree.root.expand()
|
|
23
|
+
self._ensure_add_node()
|
|
24
|
+
|
|
25
|
+
def _ensure_add_node(self):
|
|
26
|
+
"""Make sure the '+ Add connection' node is always the last leaf."""
|
|
27
|
+
for child in list(self.original_tree.root.children):
|
|
28
|
+
if child.data and child.data.get("type") == "add_connection":
|
|
29
|
+
child.remove()
|
|
30
|
+
self.original_tree.root.add_leaf("+ Add connection", data={"type": "add_connection"})
|
|
31
|
+
|
|
32
|
+
def add_connection_node(self, conn_info: dict, tables: list = None):
|
|
33
|
+
conn_id = conn_info["id"]
|
|
34
|
+
type_icon = "\U0001f42c" if conn_info["type"] == "mysql" else "\U0001f537"
|
|
35
|
+
label = f"{type_icon} {conn_info['display_name']}"
|
|
36
|
+
self.remove_connection_node(conn_id)
|
|
37
|
+
# Store tables in connection info for table filtering
|
|
38
|
+
conn_info["tables"] = tables or []
|
|
39
|
+
self.connections_data[conn_id] = conn_info
|
|
40
|
+
conn_node = self.original_tree.root.add(
|
|
41
|
+
label,
|
|
42
|
+
data={"type": "connection", "conn_id": conn_id},
|
|
43
|
+
expand=True,
|
|
44
|
+
)
|
|
45
|
+
if tables:
|
|
46
|
+
for table_name in sorted(tables):
|
|
47
|
+
conn_node.add_leaf(
|
|
48
|
+
f" {table_name}",
|
|
49
|
+
data={"type": "table", "conn_id": conn_id, "table": table_name, "database": conn_info["database"]},
|
|
50
|
+
)
|
|
51
|
+
else:
|
|
52
|
+
conn_node.add_leaf("Loading...", data={"type": "loading", "conn_id": conn_id})
|
|
53
|
+
self._ensure_add_node()
|
|
54
|
+
|
|
55
|
+
def remove_connection_node(self, conn_id: str):
|
|
56
|
+
for child in list(self.original_tree.root.children):
|
|
57
|
+
if child.data and child.data.get("conn_id") == conn_id:
|
|
58
|
+
child.remove()
|
|
59
|
+
break
|
|
60
|
+
self.connections_data.pop(conn_id, None)
|
|
61
|
+
self._ensure_add_node()
|
|
62
|
+
|
|
63
|
+
def filter_tables(self, search_term: str):
|
|
64
|
+
"""Filter the tree to show only tables that match the search term."""
|
|
65
|
+
search_term = search_term.strip().lower()
|
|
66
|
+
|
|
67
|
+
# Clear the tree and rebuild with filtered content
|
|
68
|
+
self.original_tree.clear()
|
|
69
|
+
|
|
70
|
+
for conn_id, conn_info in self.connections_data.items():
|
|
71
|
+
type_icon = "\U0001f42c" if conn_info["type"] == "mysql" else "\U0001f537"
|
|
72
|
+
label = f"{type_icon} {conn_info['display_name']}"
|
|
73
|
+
|
|
74
|
+
all_tables = conn_info.get("tables", [])
|
|
75
|
+
if search_term:
|
|
76
|
+
filtered_tables = [table for table in all_tables if search_term in table.lower()]
|
|
77
|
+
else:
|
|
78
|
+
filtered_tables = all_tables
|
|
79
|
+
|
|
80
|
+
conn_node = self.original_tree.root.add(
|
|
81
|
+
label,
|
|
82
|
+
data={"type": "connection", "conn_id": conn_id},
|
|
83
|
+
expand=True,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
if filtered_tables:
|
|
87
|
+
for table_name in sorted(filtered_tables):
|
|
88
|
+
conn_node.add_leaf(
|
|
89
|
+
f" {table_name}",
|
|
90
|
+
data={"type": "table", "conn_id": conn_id, "table": table_name, "database": conn_info["database"]},
|
|
91
|
+
)
|
|
92
|
+
elif search_term:
|
|
93
|
+
conn_node.add_leaf(
|
|
94
|
+
" No matching tables",
|
|
95
|
+
data={"type": "no_match", "conn_id": conn_id},
|
|
96
|
+
)
|
|
97
|
+
else:
|
|
98
|
+
conn_node.add_leaf("Loading...", data={"type": "loading", "conn_id": conn_id})
|
|
99
|
+
|
|
100
|
+
self._ensure_add_node()
|
|
101
|
+
self.original_tree.root.expand()
|
|
102
|
+
|
|
103
|
+
def update_tables(self, conn_id: str, tables: list):
|
|
104
|
+
conn_info = self.connections_data.get(conn_id)
|
|
105
|
+
if conn_info:
|
|
106
|
+
self.add_connection_node(conn_info, tables)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Filtered directory tree for DuckDB-supported files."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Iterable
|
|
5
|
+
from textual.widgets import DirectoryTree
|
|
6
|
+
|
|
7
|
+
from openduck.core.sql_utils import is_duckdb_file
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class DuckTree(DirectoryTree):
|
|
11
|
+
"""Directory tree that only shows directories and DuckDB-supported data files."""
|
|
12
|
+
|
|
13
|
+
def filter_paths(self, paths: Iterable[Path]) -> Iterable[Path]:
|
|
14
|
+
return [p for p in paths if p.is_dir() or is_duckdb_file(p)]
|