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.
Files changed (52) hide show
  1. nterm/__init__.py +54 -0
  2. nterm/__main__.py +619 -0
  3. nterm/askpass/__init__.py +22 -0
  4. nterm/askpass/server.py +393 -0
  5. nterm/config.py +158 -0
  6. nterm/connection/__init__.py +17 -0
  7. nterm/connection/profile.py +296 -0
  8. nterm/manager/__init__.py +29 -0
  9. nterm/manager/connect_dialog.py +322 -0
  10. nterm/manager/editor.py +262 -0
  11. nterm/manager/io.py +678 -0
  12. nterm/manager/models.py +346 -0
  13. nterm/manager/settings.py +264 -0
  14. nterm/manager/tree.py +493 -0
  15. nterm/resources.py +48 -0
  16. nterm/session/__init__.py +60 -0
  17. nterm/session/askpass_ssh.py +399 -0
  18. nterm/session/base.py +110 -0
  19. nterm/session/interactive_ssh.py +522 -0
  20. nterm/session/pty_transport.py +571 -0
  21. nterm/session/ssh.py +610 -0
  22. nterm/terminal/__init__.py +11 -0
  23. nterm/terminal/bridge.py +83 -0
  24. nterm/terminal/resources/terminal.html +253 -0
  25. nterm/terminal/resources/terminal.js +414 -0
  26. nterm/terminal/resources/xterm-addon-fit.min.js +8 -0
  27. nterm/terminal/resources/xterm-addon-unicode11.min.js +8 -0
  28. nterm/terminal/resources/xterm-addon-web-links.min.js +8 -0
  29. nterm/terminal/resources/xterm.css +209 -0
  30. nterm/terminal/resources/xterm.min.js +8 -0
  31. nterm/terminal/widget.py +380 -0
  32. nterm/theme/__init__.py +10 -0
  33. nterm/theme/engine.py +456 -0
  34. nterm/theme/stylesheet.py +377 -0
  35. nterm/theme/themes/clean.yaml +0 -0
  36. nterm/theme/themes/default.yaml +36 -0
  37. nterm/theme/themes/dracula.yaml +36 -0
  38. nterm/theme/themes/gruvbox_dark.yaml +36 -0
  39. nterm/theme/themes/gruvbox_hybrid.yaml +38 -0
  40. nterm/theme/themes/gruvbox_light.yaml +36 -0
  41. nterm/vault/__init__.py +32 -0
  42. nterm/vault/credential_manager.py +163 -0
  43. nterm/vault/keychain.py +135 -0
  44. nterm/vault/manager_ui.py +962 -0
  45. nterm/vault/profile.py +219 -0
  46. nterm/vault/resolver.py +250 -0
  47. nterm/vault/store.py +642 -0
  48. ntermqt-0.1.0.dist-info/METADATA +327 -0
  49. ntermqt-0.1.0.dist-info/RECORD +52 -0
  50. ntermqt-0.1.0.dist-info/WHEEL +5 -0
  51. ntermqt-0.1.0.dist-info/entry_points.txt +5 -0
  52. ntermqt-0.1.0.dist-info/top_level.txt +1 -0
@@ -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