ntermqt 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.
- nterm/__init__.py +54 -0
- nterm/__main__.py +619 -0
- nterm/askpass/__init__.py +22 -0
- nterm/askpass/server.py +393 -0
- nterm/config.py +158 -0
- nterm/connection/__init__.py +17 -0
- nterm/connection/profile.py +296 -0
- nterm/manager/__init__.py +29 -0
- nterm/manager/connect_dialog.py +322 -0
- nterm/manager/editor.py +262 -0
- nterm/manager/io.py +678 -0
- nterm/manager/models.py +346 -0
- nterm/manager/settings.py +264 -0
- nterm/manager/tree.py +493 -0
- nterm/resources.py +48 -0
- nterm/session/__init__.py +60 -0
- nterm/session/askpass_ssh.py +399 -0
- nterm/session/base.py +110 -0
- nterm/session/interactive_ssh.py +522 -0
- nterm/session/pty_transport.py +571 -0
- nterm/session/ssh.py +610 -0
- nterm/terminal/__init__.py +11 -0
- nterm/terminal/bridge.py +83 -0
- nterm/terminal/resources/terminal.html +253 -0
- nterm/terminal/resources/terminal.js +414 -0
- nterm/terminal/resources/xterm-addon-fit.min.js +8 -0
- nterm/terminal/resources/xterm-addon-unicode11.min.js +8 -0
- nterm/terminal/resources/xterm-addon-web-links.min.js +8 -0
- nterm/terminal/resources/xterm.css +209 -0
- nterm/terminal/resources/xterm.min.js +8 -0
- nterm/terminal/widget.py +380 -0
- nterm/theme/__init__.py +10 -0
- nterm/theme/engine.py +456 -0
- nterm/theme/stylesheet.py +377 -0
- nterm/theme/themes/clean.yaml +0 -0
- nterm/theme/themes/default.yaml +36 -0
- nterm/theme/themes/dracula.yaml +36 -0
- nterm/theme/themes/gruvbox_dark.yaml +36 -0
- nterm/theme/themes/gruvbox_hybrid.yaml +38 -0
- nterm/theme/themes/gruvbox_light.yaml +36 -0
- nterm/vault/__init__.py +32 -0
- nterm/vault/credential_manager.py +163 -0
- nterm/vault/keychain.py +135 -0
- nterm/vault/manager_ui.py +962 -0
- nterm/vault/profile.py +219 -0
- nterm/vault/resolver.py +250 -0
- nterm/vault/store.py +642 -0
- ntermqt-0.1.0.dist-info/METADATA +327 -0
- ntermqt-0.1.0.dist-info/RECORD +52 -0
- ntermqt-0.1.0.dist-info/WHEEL +5 -0
- ntermqt-0.1.0.dist-info/entry_points.txt +5 -0
- ntermqt-0.1.0.dist-info/top_level.txt +1 -0
nterm/manager/models.py
ADDED
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Session manager data models and storage.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Optional
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
import sqlite3
|
|
11
|
+
import logging
|
|
12
|
+
import json
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
# Default database location (alongside vault.db)
|
|
17
|
+
DEFAULT_DB_PATH = Path.home() / ".nterm" / "sessions.db"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class SavedSession:
|
|
22
|
+
"""A saved session bookmark."""
|
|
23
|
+
id: Optional[int] = None
|
|
24
|
+
name: str = ""
|
|
25
|
+
description: str = ""
|
|
26
|
+
hostname: str = ""
|
|
27
|
+
port: int = 22
|
|
28
|
+
|
|
29
|
+
# Reference to credential in vault (by name), or None for agent auth
|
|
30
|
+
credential_name: Optional[str] = None
|
|
31
|
+
|
|
32
|
+
# Organization
|
|
33
|
+
folder_id: Optional[int] = None
|
|
34
|
+
position: int = 0
|
|
35
|
+
|
|
36
|
+
# Metadata
|
|
37
|
+
created_at: Optional[datetime] = None
|
|
38
|
+
last_connected: Optional[datetime] = None
|
|
39
|
+
connect_count: int = 0
|
|
40
|
+
|
|
41
|
+
# Optional overrides (JSON-serialized extras)
|
|
42
|
+
extras: dict = field(default_factory=dict)
|
|
43
|
+
|
|
44
|
+
def __post_init__(self):
|
|
45
|
+
if isinstance(self.extras, str):
|
|
46
|
+
self.extras = json.loads(self.extras) if self.extras else {}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class SessionFolder:
|
|
51
|
+
"""A folder for organizing sessions."""
|
|
52
|
+
id: Optional[int] = None
|
|
53
|
+
name: str = ""
|
|
54
|
+
parent_id: Optional[int] = None # None = root level
|
|
55
|
+
position: int = 0
|
|
56
|
+
expanded: bool = True
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class SessionStore:
|
|
60
|
+
"""
|
|
61
|
+
SQLite-backed storage for saved sessions.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
def __init__(self, db_path: Path = None):
|
|
65
|
+
self.db_path = db_path or DEFAULT_DB_PATH
|
|
66
|
+
self._conn: Optional[sqlite3.Connection] = None
|
|
67
|
+
self._ensure_db()
|
|
68
|
+
|
|
69
|
+
def _ensure_db(self) -> None:
|
|
70
|
+
"""Create database and tables if needed."""
|
|
71
|
+
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
72
|
+
|
|
73
|
+
conn = sqlite3.connect(self.db_path)
|
|
74
|
+
conn.row_factory = sqlite3.Row
|
|
75
|
+
|
|
76
|
+
conn.executescript("""
|
|
77
|
+
CREATE TABLE IF NOT EXISTS folders (
|
|
78
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
79
|
+
name TEXT NOT NULL,
|
|
80
|
+
parent_id INTEGER REFERENCES folders(id) ON DELETE CASCADE,
|
|
81
|
+
position INTEGER DEFAULT 0,
|
|
82
|
+
expanded INTEGER DEFAULT 1
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
86
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
87
|
+
name TEXT NOT NULL,
|
|
88
|
+
description TEXT DEFAULT '',
|
|
89
|
+
hostname TEXT NOT NULL,
|
|
90
|
+
port INTEGER DEFAULT 22,
|
|
91
|
+
credential_name TEXT,
|
|
92
|
+
folder_id INTEGER REFERENCES folders(id) ON DELETE SET NULL,
|
|
93
|
+
position INTEGER DEFAULT 0,
|
|
94
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
95
|
+
last_connected TIMESTAMP,
|
|
96
|
+
connect_count INTEGER DEFAULT 0,
|
|
97
|
+
extras TEXT DEFAULT '{}'
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_folder ON sessions(folder_id);
|
|
101
|
+
CREATE INDEX IF NOT EXISTS idx_folders_parent ON folders(parent_id);
|
|
102
|
+
""")
|
|
103
|
+
|
|
104
|
+
conn.commit()
|
|
105
|
+
self._conn = conn
|
|
106
|
+
|
|
107
|
+
def close(self) -> None:
|
|
108
|
+
"""Close database connection."""
|
|
109
|
+
if self._conn:
|
|
110
|
+
self._conn.close()
|
|
111
|
+
self._conn = None
|
|
112
|
+
|
|
113
|
+
# -------------------------------------------------------------------------
|
|
114
|
+
# Folder operations
|
|
115
|
+
# -------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
def add_folder(self, name: str, parent_id: int = None) -> int:
|
|
118
|
+
"""Create a new folder. Returns folder ID."""
|
|
119
|
+
cursor = self._conn.execute(
|
|
120
|
+
"SELECT COALESCE(MAX(position), -1) + 1 FROM folders WHERE parent_id IS ?",
|
|
121
|
+
(parent_id,)
|
|
122
|
+
)
|
|
123
|
+
position = cursor.fetchone()[0]
|
|
124
|
+
|
|
125
|
+
cursor = self._conn.execute(
|
|
126
|
+
"INSERT INTO folders (name, parent_id, position) VALUES (?, ?, ?)",
|
|
127
|
+
(name, parent_id, position)
|
|
128
|
+
)
|
|
129
|
+
self._conn.commit()
|
|
130
|
+
return cursor.lastrowid
|
|
131
|
+
|
|
132
|
+
def get_folder(self, folder_id: int) -> Optional[SessionFolder]:
|
|
133
|
+
"""Get folder by ID."""
|
|
134
|
+
cursor = self._conn.execute(
|
|
135
|
+
"SELECT * FROM folders WHERE id = ?", (folder_id,)
|
|
136
|
+
)
|
|
137
|
+
row = cursor.fetchone()
|
|
138
|
+
return self._row_to_folder(row) if row else None
|
|
139
|
+
|
|
140
|
+
def list_folders(self, parent_id: int = None) -> list[SessionFolder]:
|
|
141
|
+
"""List folders under a parent (None = root level)."""
|
|
142
|
+
cursor = self._conn.execute(
|
|
143
|
+
"SELECT * FROM folders WHERE parent_id IS ? ORDER BY position, name",
|
|
144
|
+
(parent_id,)
|
|
145
|
+
)
|
|
146
|
+
return [self._row_to_folder(row) for row in cursor]
|
|
147
|
+
|
|
148
|
+
def update_folder(self, folder: SessionFolder) -> bool:
|
|
149
|
+
"""Update folder properties."""
|
|
150
|
+
self._conn.execute(
|
|
151
|
+
"""UPDATE folders
|
|
152
|
+
SET name = ?, parent_id = ?, position = ?, expanded = ?
|
|
153
|
+
WHERE id = ?""",
|
|
154
|
+
(folder.name, folder.parent_id, folder.position,
|
|
155
|
+
1 if folder.expanded else 0, folder.id)
|
|
156
|
+
)
|
|
157
|
+
self._conn.commit()
|
|
158
|
+
return True
|
|
159
|
+
|
|
160
|
+
def delete_folder(self, folder_id: int) -> bool:
|
|
161
|
+
"""Delete folder (sessions inside move to root)."""
|
|
162
|
+
# Move sessions to root first
|
|
163
|
+
self._conn.execute(
|
|
164
|
+
"UPDATE sessions SET folder_id = NULL WHERE folder_id = ?",
|
|
165
|
+
(folder_id,)
|
|
166
|
+
)
|
|
167
|
+
# Move subfolders to root
|
|
168
|
+
self._conn.execute(
|
|
169
|
+
"UPDATE folders SET parent_id = NULL WHERE parent_id = ?",
|
|
170
|
+
(folder_id,)
|
|
171
|
+
)
|
|
172
|
+
# Delete folder
|
|
173
|
+
self._conn.execute("DELETE FROM folders WHERE id = ?", (folder_id,))
|
|
174
|
+
self._conn.commit()
|
|
175
|
+
return True
|
|
176
|
+
|
|
177
|
+
def _row_to_folder(self, row: sqlite3.Row) -> SessionFolder:
|
|
178
|
+
return SessionFolder(
|
|
179
|
+
id=row["id"],
|
|
180
|
+
name=row["name"],
|
|
181
|
+
parent_id=row["parent_id"],
|
|
182
|
+
position=row["position"],
|
|
183
|
+
expanded=bool(row["expanded"]),
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
# -------------------------------------------------------------------------
|
|
187
|
+
# Session operations
|
|
188
|
+
# -------------------------------------------------------------------------
|
|
189
|
+
|
|
190
|
+
def add_session(self, session: SavedSession) -> int:
|
|
191
|
+
"""Add a new session. Returns session ID."""
|
|
192
|
+
cursor = self._conn.execute(
|
|
193
|
+
"SELECT COALESCE(MAX(position), -1) + 1 FROM sessions WHERE folder_id IS ?",
|
|
194
|
+
(session.folder_id,)
|
|
195
|
+
)
|
|
196
|
+
position = cursor.fetchone()[0]
|
|
197
|
+
|
|
198
|
+
cursor = self._conn.execute(
|
|
199
|
+
"""INSERT INTO sessions
|
|
200
|
+
(name, description, hostname, port, credential_name, folder_id, position, extras)
|
|
201
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
202
|
+
(session.name, session.description, session.hostname, session.port,
|
|
203
|
+
session.credential_name, session.folder_id, position,
|
|
204
|
+
json.dumps(session.extras))
|
|
205
|
+
)
|
|
206
|
+
self._conn.commit()
|
|
207
|
+
return cursor.lastrowid
|
|
208
|
+
|
|
209
|
+
def get_session(self, session_id: int) -> Optional[SavedSession]:
|
|
210
|
+
"""Get session by ID."""
|
|
211
|
+
cursor = self._conn.execute(
|
|
212
|
+
"SELECT * FROM sessions WHERE id = ?", (session_id,)
|
|
213
|
+
)
|
|
214
|
+
row = cursor.fetchone()
|
|
215
|
+
return self._row_to_session(row) if row else None
|
|
216
|
+
|
|
217
|
+
def list_sessions(self, folder_id: int = None) -> list[SavedSession]:
|
|
218
|
+
"""List sessions in a folder (None = root level)."""
|
|
219
|
+
cursor = self._conn.execute(
|
|
220
|
+
"SELECT * FROM sessions WHERE folder_id IS ? ORDER BY position, name",
|
|
221
|
+
(folder_id,)
|
|
222
|
+
)
|
|
223
|
+
return [self._row_to_session(row) for row in cursor]
|
|
224
|
+
|
|
225
|
+
def list_all_sessions(self) -> list[SavedSession]:
|
|
226
|
+
"""List all sessions regardless of folder."""
|
|
227
|
+
cursor = self._conn.execute(
|
|
228
|
+
"SELECT * FROM sessions ORDER BY name"
|
|
229
|
+
)
|
|
230
|
+
return [self._row_to_session(row) for row in cursor]
|
|
231
|
+
|
|
232
|
+
def update_session(self, session: SavedSession) -> bool:
|
|
233
|
+
"""Update session properties."""
|
|
234
|
+
self._conn.execute(
|
|
235
|
+
"""UPDATE sessions
|
|
236
|
+
SET name = ?, description = ?, hostname = ?, port = ?,
|
|
237
|
+
credential_name = ?, folder_id = ?, position = ?, extras = ?
|
|
238
|
+
WHERE id = ?""",
|
|
239
|
+
(session.name, session.description, session.hostname, session.port,
|
|
240
|
+
session.credential_name, session.folder_id, session.position,
|
|
241
|
+
json.dumps(session.extras), session.id)
|
|
242
|
+
)
|
|
243
|
+
self._conn.commit()
|
|
244
|
+
return True
|
|
245
|
+
|
|
246
|
+
def delete_session(self, session_id: int) -> bool:
|
|
247
|
+
"""Delete a session."""
|
|
248
|
+
self._conn.execute("DELETE FROM sessions WHERE id = ?", (session_id,))
|
|
249
|
+
self._conn.commit()
|
|
250
|
+
return True
|
|
251
|
+
|
|
252
|
+
def record_connect(self, session_id: int) -> None:
|
|
253
|
+
"""Record that a session was connected to."""
|
|
254
|
+
self._conn.execute(
|
|
255
|
+
"""UPDATE sessions
|
|
256
|
+
SET last_connected = CURRENT_TIMESTAMP, connect_count = connect_count + 1
|
|
257
|
+
WHERE id = ?""",
|
|
258
|
+
(session_id,)
|
|
259
|
+
)
|
|
260
|
+
self._conn.commit()
|
|
261
|
+
|
|
262
|
+
def search_sessions(self, query: str) -> list[SavedSession]:
|
|
263
|
+
"""Search sessions by name, description, or hostname."""
|
|
264
|
+
pattern = f"%{query}%"
|
|
265
|
+
cursor = self._conn.execute(
|
|
266
|
+
"""SELECT * FROM sessions
|
|
267
|
+
WHERE name LIKE ? OR description LIKE ? OR hostname LIKE ?
|
|
268
|
+
ORDER BY name""",
|
|
269
|
+
(pattern, pattern, pattern)
|
|
270
|
+
)
|
|
271
|
+
return [self._row_to_session(row) for row in cursor]
|
|
272
|
+
|
|
273
|
+
def _row_to_session(self, row: sqlite3.Row) -> SavedSession:
|
|
274
|
+
return SavedSession(
|
|
275
|
+
id=row["id"],
|
|
276
|
+
name=row["name"],
|
|
277
|
+
description=row["description"],
|
|
278
|
+
hostname=row["hostname"],
|
|
279
|
+
port=row["port"],
|
|
280
|
+
credential_name=row["credential_name"],
|
|
281
|
+
folder_id=row["folder_id"],
|
|
282
|
+
position=row["position"],
|
|
283
|
+
created_at=row["created_at"],
|
|
284
|
+
last_connected=row["last_connected"],
|
|
285
|
+
connect_count=row["connect_count"],
|
|
286
|
+
extras=row["extras"],
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
# -------------------------------------------------------------------------
|
|
290
|
+
# Bulk / tree operations
|
|
291
|
+
# -------------------------------------------------------------------------
|
|
292
|
+
|
|
293
|
+
def get_tree(self) -> dict:
|
|
294
|
+
"""
|
|
295
|
+
Get full tree structure for UI.
|
|
296
|
+
|
|
297
|
+
Returns dict with:
|
|
298
|
+
- folders: list of SessionFolder (all)
|
|
299
|
+
- sessions: list of SavedSession (all)
|
|
300
|
+
"""
|
|
301
|
+
folders = []
|
|
302
|
+
cursor = self._conn.execute("SELECT * FROM folders ORDER BY position, name")
|
|
303
|
+
for row in cursor:
|
|
304
|
+
folders.append(self._row_to_folder(row))
|
|
305
|
+
|
|
306
|
+
sessions = self.list_all_sessions()
|
|
307
|
+
|
|
308
|
+
return {"folders": folders, "sessions": sessions}
|
|
309
|
+
|
|
310
|
+
def move_session(self, session_id: int, folder_id: int = None) -> None:
|
|
311
|
+
"""Move session to a different folder."""
|
|
312
|
+
# Get new position at end of target folder
|
|
313
|
+
cursor = self._conn.execute(
|
|
314
|
+
"SELECT COALESCE(MAX(position), -1) + 1 FROM sessions WHERE folder_id IS ?",
|
|
315
|
+
(folder_id,)
|
|
316
|
+
)
|
|
317
|
+
position = cursor.fetchone()[0]
|
|
318
|
+
|
|
319
|
+
self._conn.execute(
|
|
320
|
+
"UPDATE sessions SET folder_id = ?, position = ? WHERE id = ?",
|
|
321
|
+
(folder_id, position, session_id)
|
|
322
|
+
)
|
|
323
|
+
self._conn.commit()
|
|
324
|
+
|
|
325
|
+
def move_folder(self, folder_id: int, parent_id: int = None) -> None:
|
|
326
|
+
"""Move folder to a different parent."""
|
|
327
|
+
# Prevent circular reference
|
|
328
|
+
if parent_id:
|
|
329
|
+
current = parent_id
|
|
330
|
+
while current:
|
|
331
|
+
if current == folder_id:
|
|
332
|
+
raise ValueError("Cannot move folder into itself")
|
|
333
|
+
folder = self.get_folder(current)
|
|
334
|
+
current = folder.parent_id if folder else None
|
|
335
|
+
|
|
336
|
+
cursor = self._conn.execute(
|
|
337
|
+
"SELECT COALESCE(MAX(position), -1) + 1 FROM folders WHERE parent_id IS ?",
|
|
338
|
+
(parent_id,)
|
|
339
|
+
)
|
|
340
|
+
position = cursor.fetchone()[0]
|
|
341
|
+
|
|
342
|
+
self._conn.execute(
|
|
343
|
+
"UPDATE folders SET parent_id = ?, position = ? WHERE id = ?",
|
|
344
|
+
(parent_id, position, folder_id)
|
|
345
|
+
)
|
|
346
|
+
self._conn.commit()
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Settings dialog with theme selection and persistence.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
from PyQt6.QtWidgets import (
|
|
9
|
+
QDialog, QVBoxLayout, QHBoxLayout, QFormLayout,
|
|
10
|
+
QComboBox, QSpinBox, QPushButton,
|
|
11
|
+
QDialogButtonBox, QGroupBox, QLabel, QWidget,
|
|
12
|
+
QFrame, QCheckBox
|
|
13
|
+
)
|
|
14
|
+
from PyQt6.QtCore import pyqtSignal
|
|
15
|
+
from PyQt6.QtGui import QFont
|
|
16
|
+
|
|
17
|
+
from nterm.theme.engine import ThemeEngine, Theme
|
|
18
|
+
from nterm.config import get_settings, save_settings, AppSettings
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ThemePreview(QFrame):
|
|
22
|
+
"""Small preview of theme colors."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, parent=None):
|
|
25
|
+
super().__init__(parent)
|
|
26
|
+
self.setFixedHeight(60)
|
|
27
|
+
self.setFrameStyle(QFrame.Shape.Box | QFrame.Shadow.Sunken)
|
|
28
|
+
self._theme: Optional[Theme] = None
|
|
29
|
+
self._update_style()
|
|
30
|
+
|
|
31
|
+
def set_theme(self, theme: Theme) -> None:
|
|
32
|
+
"""Update preview with theme colors."""
|
|
33
|
+
self._theme = theme
|
|
34
|
+
self._update_style()
|
|
35
|
+
|
|
36
|
+
def _update_style(self) -> None:
|
|
37
|
+
if not self._theme:
|
|
38
|
+
return
|
|
39
|
+
|
|
40
|
+
colors = self._theme.terminal_colors
|
|
41
|
+
bg = colors.get("background", "#1e1e2e")
|
|
42
|
+
fg = colors.get("foreground", "#cdd6f4")
|
|
43
|
+
|
|
44
|
+
# Build color swatches
|
|
45
|
+
swatch_colors = [
|
|
46
|
+
colors.get("red", "#f38ba8"),
|
|
47
|
+
colors.get("green", "#a6e3a1"),
|
|
48
|
+
colors.get("yellow", "#f9e2af"),
|
|
49
|
+
colors.get("blue", "#89b4fa"),
|
|
50
|
+
colors.get("magenta", "#f5c2e7"),
|
|
51
|
+
colors.get("cyan", "#94e2d5"),
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
self.setStyleSheet(f"""
|
|
55
|
+
ThemePreview {{
|
|
56
|
+
background-color: {bg};
|
|
57
|
+
border: 1px solid {self._theme.border_color};
|
|
58
|
+
border-radius: 4px;
|
|
59
|
+
}}
|
|
60
|
+
""")
|
|
61
|
+
|
|
62
|
+
# Clear existing widgets
|
|
63
|
+
if self.layout():
|
|
64
|
+
while self.layout().count():
|
|
65
|
+
child = self.layout().takeAt(0)
|
|
66
|
+
if child.widget():
|
|
67
|
+
child.widget().deleteLater()
|
|
68
|
+
else:
|
|
69
|
+
layout = QHBoxLayout(self)
|
|
70
|
+
layout.setContentsMargins(8, 8, 8, 8)
|
|
71
|
+
layout.setSpacing(4)
|
|
72
|
+
|
|
73
|
+
# Add sample text
|
|
74
|
+
sample = QLabel("user@host:~$")
|
|
75
|
+
sample.setStyleSheet(f"color: {fg}; background: transparent;")
|
|
76
|
+
sample.setFont(QFont(self._theme.font_family.split(",")[0].strip(), 11))
|
|
77
|
+
self.layout().addWidget(sample)
|
|
78
|
+
|
|
79
|
+
self.layout().addStretch()
|
|
80
|
+
|
|
81
|
+
# Add color swatches
|
|
82
|
+
for color in swatch_colors:
|
|
83
|
+
swatch = QFrame()
|
|
84
|
+
swatch.setFixedSize(16, 16)
|
|
85
|
+
swatch.setStyleSheet(f"""
|
|
86
|
+
background-color: {color};
|
|
87
|
+
border-radius: 2px;
|
|
88
|
+
""")
|
|
89
|
+
self.layout().addWidget(swatch)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class SettingsDialog(QDialog):
|
|
93
|
+
"""
|
|
94
|
+
Application settings dialog with persistence.
|
|
95
|
+
|
|
96
|
+
Signals:
|
|
97
|
+
theme_changed(theme): Emitted when theme selection changes
|
|
98
|
+
settings_changed(settings): Emitted when any settings change
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
theme_changed = pyqtSignal(object) # Theme
|
|
102
|
+
settings_changed = pyqtSignal(object) # AppSettings
|
|
103
|
+
|
|
104
|
+
def __init__(
|
|
105
|
+
self,
|
|
106
|
+
theme_engine: ThemeEngine,
|
|
107
|
+
current_theme: Theme = None,
|
|
108
|
+
parent: QWidget = None
|
|
109
|
+
):
|
|
110
|
+
super().__init__(parent)
|
|
111
|
+
self.theme_engine = theme_engine
|
|
112
|
+
self._settings = get_settings()
|
|
113
|
+
self._current_theme = current_theme or theme_engine.current
|
|
114
|
+
self._original_theme = self._current_theme
|
|
115
|
+
self._original_settings = AppSettings.from_dict(self._settings.to_dict())
|
|
116
|
+
|
|
117
|
+
self._setup_ui()
|
|
118
|
+
self._load_settings()
|
|
119
|
+
|
|
120
|
+
def _setup_ui(self) -> None:
|
|
121
|
+
"""Build the dialog UI."""
|
|
122
|
+
self.setWindowTitle("Settings")
|
|
123
|
+
self.setMinimumWidth(450)
|
|
124
|
+
|
|
125
|
+
layout = QVBoxLayout(self)
|
|
126
|
+
|
|
127
|
+
# Theme group
|
|
128
|
+
theme_group = QGroupBox("Appearance")
|
|
129
|
+
theme_layout = QVBoxLayout(theme_group)
|
|
130
|
+
|
|
131
|
+
# Theme selector
|
|
132
|
+
selector_row = QHBoxLayout()
|
|
133
|
+
selector_row.addWidget(QLabel("Theme:"))
|
|
134
|
+
|
|
135
|
+
self._theme_combo = QComboBox()
|
|
136
|
+
for name in self.theme_engine.list_themes():
|
|
137
|
+
self._theme_combo.addItem(name.replace("_", " ").title(), name)
|
|
138
|
+
self._theme_combo.currentIndexChanged.connect(self._on_theme_changed)
|
|
139
|
+
selector_row.addWidget(self._theme_combo, 1)
|
|
140
|
+
|
|
141
|
+
theme_layout.addLayout(selector_row)
|
|
142
|
+
|
|
143
|
+
# Theme preview
|
|
144
|
+
self._preview = ThemePreview()
|
|
145
|
+
theme_layout.addWidget(self._preview)
|
|
146
|
+
|
|
147
|
+
layout.addWidget(theme_group)
|
|
148
|
+
|
|
149
|
+
# Font group
|
|
150
|
+
font_group = QGroupBox("Terminal Font")
|
|
151
|
+
font_layout = QFormLayout(font_group)
|
|
152
|
+
|
|
153
|
+
self._font_size_spin = QSpinBox()
|
|
154
|
+
self._font_size_spin.setRange(8, 32)
|
|
155
|
+
self._font_size_spin.setValue(14)
|
|
156
|
+
self._font_size_spin.setSuffix(" pt")
|
|
157
|
+
font_layout.addRow("Size:", self._font_size_spin)
|
|
158
|
+
|
|
159
|
+
layout.addWidget(font_group)
|
|
160
|
+
|
|
161
|
+
# Terminal behavior group
|
|
162
|
+
behavior_group = QGroupBox("Terminal Behavior")
|
|
163
|
+
behavior_layout = QFormLayout(behavior_group)
|
|
164
|
+
|
|
165
|
+
self._multiline_spin = QSpinBox()
|
|
166
|
+
self._multiline_spin.setRange(0, 100)
|
|
167
|
+
self._multiline_spin.setValue(self._settings.multiline_paste_threshold)
|
|
168
|
+
self._multiline_spin.setSpecialValueText("Disabled")
|
|
169
|
+
self._multiline_spin.setToolTip("Warn before pasting text with more than this many lines (0 to disable)")
|
|
170
|
+
behavior_layout.addRow("Multiline paste warning:", self._multiline_spin)
|
|
171
|
+
|
|
172
|
+
self._scrollback_spin = QSpinBox()
|
|
173
|
+
self._scrollback_spin.setRange(1000, 100000)
|
|
174
|
+
self._scrollback_spin.setSingleStep(1000)
|
|
175
|
+
self._scrollback_spin.setValue(self._settings.scrollback_lines)
|
|
176
|
+
self._scrollback_spin.setSuffix(" lines")
|
|
177
|
+
behavior_layout.addRow("Scrollback buffer:", self._scrollback_spin)
|
|
178
|
+
|
|
179
|
+
self._auto_reconnect_check = QCheckBox()
|
|
180
|
+
self._auto_reconnect_check.setChecked(self._settings.auto_reconnect)
|
|
181
|
+
self._auto_reconnect_check.setToolTip("Automatically attempt to reconnect when connection is lost")
|
|
182
|
+
behavior_layout.addRow("Auto-reconnect:", self._auto_reconnect_check)
|
|
183
|
+
|
|
184
|
+
layout.addWidget(behavior_group)
|
|
185
|
+
|
|
186
|
+
# Spacer
|
|
187
|
+
layout.addStretch()
|
|
188
|
+
|
|
189
|
+
# Buttons
|
|
190
|
+
buttons = QDialogButtonBox(
|
|
191
|
+
QDialogButtonBox.StandardButton.Ok |
|
|
192
|
+
QDialogButtonBox.StandardButton.Cancel |
|
|
193
|
+
QDialogButtonBox.StandardButton.Apply
|
|
194
|
+
)
|
|
195
|
+
buttons.accepted.connect(self._on_accept)
|
|
196
|
+
buttons.rejected.connect(self._on_reject)
|
|
197
|
+
buttons.button(QDialogButtonBox.StandardButton.Apply).clicked.connect(self._apply)
|
|
198
|
+
layout.addWidget(buttons)
|
|
199
|
+
|
|
200
|
+
def _load_settings(self) -> None:
|
|
201
|
+
"""Load current settings into form."""
|
|
202
|
+
# Select current theme
|
|
203
|
+
idx = self._theme_combo.findData(self._settings.theme_name)
|
|
204
|
+
if idx >= 0:
|
|
205
|
+
self._theme_combo.setCurrentIndex(idx)
|
|
206
|
+
else:
|
|
207
|
+
# Fallback to current theme
|
|
208
|
+
idx = self._theme_combo.findData(self._current_theme.name)
|
|
209
|
+
if idx >= 0:
|
|
210
|
+
self._theme_combo.setCurrentIndex(idx)
|
|
211
|
+
|
|
212
|
+
self._preview.set_theme(self._current_theme)
|
|
213
|
+
self._font_size_spin.setValue(self._settings.font_size)
|
|
214
|
+
self._multiline_spin.setValue(self._settings.multiline_paste_threshold)
|
|
215
|
+
self._scrollback_spin.setValue(self._settings.scrollback_lines)
|
|
216
|
+
self._auto_reconnect_check.setChecked(self._settings.auto_reconnect)
|
|
217
|
+
|
|
218
|
+
def _on_theme_changed(self, index: int) -> None:
|
|
219
|
+
"""Handle theme selection change."""
|
|
220
|
+
theme_name = self._theme_combo.currentData()
|
|
221
|
+
theme = self.theme_engine.get_theme(theme_name)
|
|
222
|
+
if theme:
|
|
223
|
+
self._current_theme = theme
|
|
224
|
+
self._preview.set_theme(theme)
|
|
225
|
+
|
|
226
|
+
def _apply(self) -> None:
|
|
227
|
+
"""Apply current settings and persist."""
|
|
228
|
+
# Update settings object
|
|
229
|
+
self._settings.theme_name = self._theme_combo.currentData()
|
|
230
|
+
self._settings.font_size = self._font_size_spin.value()
|
|
231
|
+
self._settings.multiline_paste_threshold = self._multiline_spin.value()
|
|
232
|
+
self._settings.scrollback_lines = self._scrollback_spin.value()
|
|
233
|
+
self._settings.auto_reconnect = self._auto_reconnect_check.isChecked()
|
|
234
|
+
|
|
235
|
+
# Update font size on theme
|
|
236
|
+
self._current_theme.font_size = self._font_size_spin.value()
|
|
237
|
+
self.theme_engine.current = self._current_theme
|
|
238
|
+
|
|
239
|
+
# Save to disk
|
|
240
|
+
save_settings()
|
|
241
|
+
|
|
242
|
+
# Emit signals
|
|
243
|
+
self.theme_changed.emit(self._current_theme)
|
|
244
|
+
self.settings_changed.emit(self._settings)
|
|
245
|
+
|
|
246
|
+
def _on_accept(self) -> None:
|
|
247
|
+
"""Accept and close."""
|
|
248
|
+
self._apply()
|
|
249
|
+
self.accept()
|
|
250
|
+
|
|
251
|
+
def _on_reject(self) -> None:
|
|
252
|
+
"""Cancel - revert to original theme."""
|
|
253
|
+
if self._current_theme != self._original_theme:
|
|
254
|
+
self.theme_engine.current = self._original_theme
|
|
255
|
+
self.theme_changed.emit(self._original_theme)
|
|
256
|
+
self.reject()
|
|
257
|
+
|
|
258
|
+
def get_theme(self) -> Theme:
|
|
259
|
+
"""Get selected theme."""
|
|
260
|
+
return self._current_theme
|
|
261
|
+
|
|
262
|
+
def get_settings(self) -> AppSettings:
|
|
263
|
+
"""Get current settings."""
|
|
264
|
+
return self._settings
|