]> jfr.im git - irc/weechat/qweechat.git/blame - qweechat/qweechat.py
Rename option/field "server" to "hostname"
[irc/weechat/qweechat.git] / qweechat / qweechat.py
CommitLineData
77df9d06
SH
1# -*- coding: utf-8 -*-
2#
3# qweechat.py - WeeChat remote GUI using Qt toolkit
4#
8335009d 5# Copyright (C) 2011-2021 Sébastien Helleu <flashcode@flashtux.org>
77df9d06
SH
6#
7# This file is part of QWeeChat, a Qt remote GUI for WeeChat.
8#
9# QWeeChat is free software; you can redistribute it and/or modify
10# it under the terms of the GNU General Public License as published by
11# the Free Software Foundation; either version 3 of the License, or
12# (at your option) any later version.
13#
14# QWeeChat is distributed in the hope that it will be useful,
15# but WITHOUT ANY WARRANTY; without even the implied warranty of
16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17# GNU General Public License for more details.
18#
19# You should have received a copy of the GNU General Public License
20# along with QWeeChat. If not, see <http://www.gnu.org/licenses/>.
21#
22
23"""
24QWeeChat is a WeeChat remote GUI using Qt toolkit.
25
26It requires requires WeeChat 0.3.7 or newer, running on local or remote host.
27"""
28
29#
30# History:
31#
32# 2011-05-27, Sébastien Helleu <flashcode@flashtux.org>:
33# start dev
34#
35
36import sys
37import traceback
38from pkg_resources import resource_filename
8e15c19f 39
f3da694d 40from PySide6 import QtCore, QtGui, QtWidgets
8e15c19f 41
0ee7d2e9 42from qweechat import config
0ee7d2e9 43from qweechat.about import AboutDialog
3b0947c9
SH
44from qweechat.buffer import BufferListWidget, Buffer
45from qweechat.connection import ConnectionDialog
46from qweechat.network import Network, STATUS_DISCONNECTED, NETWORK_STATUS
47from qweechat.preferences import PreferencesDialog
48from qweechat.weechat import protocol
77df9d06 49
1f27a20e 50
beddac26 51APP_NAME = 'QWeeChat'
77df9d06 52AUTHOR = 'Sébastien Helleu'
9036a59a 53WEECHAT_SITE = 'https://weechat.org/'
77df9d06 54
77df9d06 55
1f27a20e 56class MainWindow(QtWidgets.QMainWindow):
77df9d06
SH
57 """Main window."""
58
59 def __init__(self, *args):
1fe88536 60 super().__init__(*args)
77df9d06
SH
61
62 self.config = config.read()
63
64 self.resize(1000, 600)
beddac26 65 self.setWindowTitle(APP_NAME)
77df9d06 66
77df9d06
SH
67 self.about_dialog = None
68 self.connection_dialog = None
0bd929dc 69 self.preferences_dialog = None
77df9d06
SH
70
71 # network
72 self.network = Network()
73 self.network.statusChanged.connect(self._network_status_changed)
74 self.network.messageFromWeechat.connect(self._network_weechat_msg)
75
76 # list of buffers
77 self.list_buffers = BufferListWidget()
78 self.list_buffers.currentRowChanged.connect(self._buffer_switch)
79
80 # default buffer
81 self.buffers = [Buffer()]
1f27a20e 82 self.stacked_buffers = QtWidgets.QStackedWidget()
77df9d06
SH
83 self.stacked_buffers.addWidget(self.buffers[0].widget)
84
85 # splitter with buffers + chat/input
1f27a20e 86 splitter = QtWidgets.QSplitter()
77df9d06
SH
87 splitter.addWidget(self.list_buffers)
88 splitter.addWidget(self.stacked_buffers)
89
90 self.setCentralWidget(splitter)
91
92 if self.config.getboolean('look', 'statusbar'):
93 self.statusBar().visible = True
94
95 # actions for menu and toolbar
96 actions_def = {
97 'connect': [
3b0947c9
SH
98 'network-connect.png',
99 'Connect to WeeChat',
100 'Ctrl+O',
101 self.open_connection_dialog,
102 ],
77df9d06 103 'disconnect': [
3b0947c9
SH
104 'network-disconnect.png',
105 'Disconnect from WeeChat',
106 'Ctrl+D',
107 self.network.disconnect_weechat,
108 ],
77df9d06 109 'debug': [
3b0947c9
SH
110 'edit-find.png',
111 'Open debug console window',
112 'Ctrl+B',
113 self.network.open_debug_dialog,
114 ],
77df9d06 115 'preferences': [
3b0947c9
SH
116 'preferences-other.png',
117 'Change preferences',
118 'Ctrl+P',
119 self.open_preferences_dialog,
120 ],
77df9d06 121 'about': [
3b0947c9
SH
122 'help-about.png',
123 'About QWeeChat',
124 'Ctrl+H',
125 self.open_about_dialog,
126 ],
77df9d06 127 'save connection': [
3b0947c9
SH
128 'document-save.png',
129 'Save connection configuration',
130 'Ctrl+S',
131 self.save_connection,
132 ],
77df9d06 133 'quit': [
3b0947c9
SH
134 'application-exit.png',
135 'Quit application',
136 'Ctrl+Q',
137 self.close,
138 ],
77df9d06
SH
139 }
140 self.actions = {}
141 for name, action in list(actions_def.items()):
356719e0
SH
142 self.actions[name] = QtGui.QAction(
143 QtGui.QIcon(
77df9d06
SH
144 resource_filename(__name__, 'data/icons/%s' % action[0])),
145 name.capitalize(), self)
3b0947c9 146 self.actions[name].setToolTip(f'{action[1]} ({action[2]})')
77df9d06
SH
147 self.actions[name].setShortcut(action[2])
148 self.actions[name].triggered.connect(action[3])
149
150 # menu
151 self.menu = self.menuBar()
152 menu_file = self.menu.addMenu('&File')
153 menu_file.addActions([self.actions['connect'],
154 self.actions['disconnect'],
155 self.actions['preferences'],
156 self.actions['save connection'],
157 self.actions['quit']])
158 menu_window = self.menu.addMenu('&Window')
159 menu_window.addAction(self.actions['debug'])
160 menu_help = self.menu.addMenu('&Help')
161 menu_help.addAction(self.actions['about'])
1f27a20e 162 self.network_status = QtWidgets.QLabel()
77df9d06
SH
163 self.network_status.setFixedHeight(20)
164 self.network_status.setFixedWidth(200)
165 self.network_status.setContentsMargins(0, 0, 10, 0)
356719e0 166 self.network_status.setAlignment(QtCore.Qt.AlignRight)
77df9d06
SH
167 if hasattr(self.menu, 'setCornerWidget'):
168 self.menu.setCornerWidget(self.network_status,
356719e0 169 QtCore.Qt.TopRightCorner)
6958c235 170 self.network_status_set(STATUS_DISCONNECTED)
77df9d06
SH
171
172 # toolbar
173 toolbar = self.addToolBar('toolBar')
356719e0 174 toolbar.setToolButtonStyle(QtCore.Qt.ToolButtonTextUnderIcon)
77df9d06
SH
175 toolbar.addActions([self.actions['connect'],
176 self.actions['disconnect'],
177 self.actions['debug'],
178 self.actions['preferences'],
179 self.actions['about'],
180 self.actions['quit']])
181
182 self.buffers[0].widget.input.setFocus()
183
184 # open debug dialog
185 if self.config.getboolean('look', 'debug'):
3b0947c9 186 self.network.open_debug_dialog()
77df9d06
SH
187
188 # auto-connect to relay
189 if self.config.getboolean('relay', 'autoconnect'):
3b0947c9 190 self.network.connect_weechat(
ae648fd7
SH
191 hostname=self.config.get('relay', 'hostname', fallback=''),
192 port=self.config.get('relay', 'port', fallback=''),
193 ssl=self.config.getboolean('relay', 'ssl', fallback=''),
194 password=self.config.get('relay', 'password', fallback=''),
3b0947c9 195 totp=None,
ae648fd7 196 lines=self.config.get('relay', 'lines', fallback=''),
3b0947c9 197 )
77df9d06
SH
198
199 self.show()
200
201 def _buffer_switch(self, index):
202 """Switch to a buffer."""
203 if index >= 0:
204 self.stacked_buffers.setCurrentIndex(index)
205 self.stacked_buffers.widget(index).input.setFocus()
206
207 def buffer_input(self, full_name, text):
208 """Send buffer input to WeeChat."""
209 if self.network.is_connected():
210 message = 'input %s %s\n' % (full_name, text)
211 self.network.send_to_weechat(message)
3b0947c9 212 self.network.debug_print(0, '<==', message, forcecolor='#AA0000')
77df9d06
SH
213
214 def open_preferences_dialog(self):
215 """Open a dialog with preferences."""
0bd929dc 216 # TODO: implement the preferences dialog box
3b0947c9 217 self.preferences_dialog = PreferencesDialog(self)
77df9d06
SH
218
219 def save_connection(self):
220 """Save connection configuration."""
221 if self.network:
222 options = self.network.get_options()
1fe88536 223 for option in options:
77df9d06
SH
224 self.config.set('relay', option, options[option])
225
77df9d06
SH
226 def open_about_dialog(self):
227 """Open a dialog with info about QWeeChat."""
beddac26 228 self.about_dialog = AboutDialog(APP_NAME, AUTHOR, WEECHAT_SITE, self)
77df9d06
SH
229
230 def open_connection_dialog(self):
231 """Open a dialog with connection settings."""
232 values = {}
ae648fd7
SH
233 for option in ('hostname', 'port', 'ssl', 'password', 'lines'):
234 values[option] = self.config.get('relay', option, fallback='')
77df9d06
SH
235 self.connection_dialog = ConnectionDialog(values, self)
236 self.connection_dialog.dialog_buttons.accepted.connect(
237 self.connect_weechat)
238
239 def connect_weechat(self):
240 """Connect to WeeChat."""
241 self.network.connect_weechat(
ae648fd7 242 hostname=self.connection_dialog.fields['hostname'].text(),
3b0947c9
SH
243 port=self.connection_dialog.fields['port'].text(),
244 ssl=self.connection_dialog.fields['ssl'].isChecked(),
245 password=self.connection_dialog.fields['password'].text(),
246 totp=self.connection_dialog.fields['totp'].text(),
247 lines=int(self.connection_dialog.fields['lines'].text()),
248 )
77df9d06
SH
249 self.connection_dialog.close()
250
251 def _network_status_changed(self, status, extra):
252 """Called when the network status has changed."""
253 if self.config.getboolean('look', 'statusbar'):
254 self.statusBar().showMessage(status)
3b0947c9 255 self.network.debug_print(0, '', status, forcecolor='#0000AA')
77df9d06
SH
256 self.network_status_set(status)
257
258 def network_status_set(self, status):
259 """Set the network status."""
260 pal = self.network_status.palette()
6958c235
SH
261 pal.setColor(self.network_status.foregroundRole(),
262 self.network.status_color(status))
263 ssl = ' (SSL)' if status != STATUS_DISCONNECTED \
77df9d06
SH
264 and self.network.is_ssl() else ''
265 self.network_status.setPalette(pal)
266 icon = self.network.status_icon(status)
267 if icon:
268 self.network_status.setText(
269 '<img src="%s"> %s' %
270 (resource_filename(__name__, 'data/icons/%s' % icon),
6958c235 271 self.network.status_label(status) + ssl))
77df9d06
SH
272 else:
273 self.network_status.setText(status.capitalize())
6958c235 274 if status == STATUS_DISCONNECTED:
77df9d06
SH
275 self.actions['connect'].setEnabled(True)
276 self.actions['disconnect'].setEnabled(False)
277 else:
278 self.actions['connect'].setEnabled(False)
279 self.actions['disconnect'].setEnabled(True)
280
281 def _network_weechat_msg(self, message):
282 """Called when a message is received from WeeChat."""
3b0947c9
SH
283 self.network.debug_print(
284 0, '==>',
285 'message (%d bytes):\n%s'
286 % (len(message),
287 protocol.hex_and_ascii(message.data(), 20)),
288 forcecolor='#008800',
289 )
77df9d06
SH
290 try:
291 proto = protocol.Protocol()
1f27a20e 292 message = proto.decode(message.data())
77df9d06 293 if message.uncompressed:
3b0947c9 294 self.network.debug_print(
77df9d06
SH
295 0, '==>',
296 'message uncompressed (%d bytes):\n%s'
297 % (message.size_uncompressed,
298 protocol.hex_and_ascii(message.uncompressed, 20)),
7a3a48a5 299 forcecolor='#008800')
3b0947c9 300 self.network.debug_print(0, '', 'Message: %s' % message)
77df9d06 301 self.parse_message(message)
1f27a20e 302 except Exception: # noqa: E722
77df9d06
SH
303 print('Error while decoding message from WeeChat:\n%s'
304 % traceback.format_exc())
305 self.network.disconnect_weechat()
306
3b0947c9
SH
307 def _parse_handshake(self, message):
308 """Parse a WeeChat message with handshake response."""
309 for obj in message.objects:
310 if obj.objtype != 'htb':
311 continue
312 self.network.init_with_handshake(obj.value)
313 break
314
77df9d06 315 def _parse_listbuffers(self, message):
3b0947c9 316 """Parse a WeeChat message with list of buffers."""
77df9d06
SH
317 for obj in message.objects:
318 if obj.objtype != 'hda' or obj.value['path'][-1] != 'buffer':
319 continue
320 self.list_buffers.clear()
321 while self.stacked_buffers.count() > 0:
322 buf = self.stacked_buffers.widget(0)
323 self.stacked_buffers.removeWidget(buf)
324 self.buffers = []
325 for item in obj.value['items']:
326 buf = self.create_buffer(item)
327 self.insert_buffer(len(self.buffers), buf)
328 self.list_buffers.setCurrentRow(0)
329 self.buffers[0].widget.input.setFocus()
330
331 def _parse_line(self, message):
332 """Parse a WeeChat message with a buffer line."""
333 for obj in message.objects:
334 lines = []
335 if obj.objtype != 'hda' or obj.value['path'][-1] != 'line_data':
336 continue
337 for item in obj.value['items']:
338 if message.msgid == 'listlines':
339 ptrbuf = item['__path'][0]
340 else:
341 ptrbuf = item['buffer']
342 index = [i for i, b in enumerate(self.buffers)
343 if b.pointer() == ptrbuf]
344 if index:
e38ef663
SH
345 lines.append(
346 (index[0],
347 (item['date'], item['prefix'],
348 item['message']))
349 )
77df9d06
SH
350 if message.msgid == 'listlines':
351 lines.reverse()
352 for line in lines:
e38ef663 353 self.buffers[line[0]].widget.chat.display(*line[1])
77df9d06
SH
354
355 def _parse_nicklist(self, message):
356 """Parse a WeeChat message with a buffer nicklist."""
357 buffer_refresh = {}
358 for obj in message.objects:
359 if obj.objtype != 'hda' or \
360 obj.value['path'][-1] != 'nicklist_item':
361 continue
362 group = '__root'
363 for item in obj.value['items']:
364 index = [i for i, b in enumerate(self.buffers)
365 if b.pointer() == item['__path'][0]]
366 if index:
367 if not index[0] in buffer_refresh:
368 self.buffers[index[0]].nicklist = {}
369 buffer_refresh[index[0]] = True
370 if item['group']:
371 group = item['name']
372 self.buffers[index[0]].nicklist_add_item(
373 group, item['group'], item['prefix'], item['name'],
374 item['visible'])
375 for index in buffer_refresh:
376 self.buffers[index].nicklist_refresh()
377
378 def _parse_nicklist_diff(self, message):
379 """Parse a WeeChat message with a buffer nicklist diff."""
380 buffer_refresh = {}
381 for obj in message.objects:
382 if obj.objtype != 'hda' or \
383 obj.value['path'][-1] != 'nicklist_item':
384 continue
385 group = '__root'
386 for item in obj.value['items']:
387 index = [i for i, b in enumerate(self.buffers)
388 if b.pointer() == item['__path'][0]]
389 if not index:
390 continue
391 buffer_refresh[index[0]] = True
392 if item['_diff'] == ord('^'):
393 group = item['name']
394 elif item['_diff'] == ord('+'):
395 self.buffers[index[0]].nicklist_add_item(
396 group, item['group'], item['prefix'], item['name'],
397 item['visible'])
398 elif item['_diff'] == ord('-'):
399 self.buffers[index[0]].nicklist_remove_item(
400 group, item['group'], item['name'])
401 elif item['_diff'] == ord('*'):
402 self.buffers[index[0]].nicklist_update_item(
403 group, item['group'], item['prefix'], item['name'],
404 item['visible'])
405 for index in buffer_refresh:
406 self.buffers[index].nicklist_refresh()
407
408 def _parse_buffer_opened(self, message):
409 """Parse a WeeChat message with a new buffer (opened)."""
410 for obj in message.objects:
411 if obj.objtype != 'hda' or obj.value['path'][-1] != 'buffer':
412 continue
413 for item in obj.value['items']:
414 buf = self.create_buffer(item)
415 index = self.find_buffer_index_for_insert(item['next_buffer'])
416 self.insert_buffer(index, buf)
417
418 def _parse_buffer(self, message):
419 """Parse a WeeChat message with a buffer event
420 (anything except a new buffer).
421 """
422 for obj in message.objects:
423 if obj.objtype != 'hda' or obj.value['path'][-1] != 'buffer':
424 continue
425 for item in obj.value['items']:
426 index = [i for i, b in enumerate(self.buffers)
427 if b.pointer() == item['__path'][0]]
428 if not index:
429 continue
430 index = index[0]
431 if message.msgid == '_buffer_type_changed':
432 self.buffers[index].data['type'] = item['type']
433 elif message.msgid in ('_buffer_moved', '_buffer_merged',
434 '_buffer_unmerged'):
435 buf = self.buffers[index]
436 buf.data['number'] = item['number']
437 self.remove_buffer(index)
438 index2 = self.find_buffer_index_for_insert(
439 item['next_buffer'])
440 self.insert_buffer(index2, buf)
441 elif message.msgid == '_buffer_renamed':
442 self.buffers[index].data['full_name'] = item['full_name']
443 self.buffers[index].data['short_name'] = item['short_name']
444 elif message.msgid == '_buffer_title_changed':
445 self.buffers[index].data['title'] = item['title']
446 self.buffers[index].update_title()
8217c4e2
SH
447 elif message.msgid == '_buffer_cleared':
448 self.buffers[index].widget.chat.clear()
77df9d06
SH
449 elif message.msgid.startswith('_buffer_localvar_'):
450 self.buffers[index].data['local_variables'] = \
451 item['local_variables']
452 self.buffers[index].update_prompt()
453 elif message.msgid == '_buffer_closing':
454 self.remove_buffer(index)
455
456 def parse_message(self, message):
457 """Parse a WeeChat message."""
458 if message.msgid.startswith('debug'):
3b0947c9
SH
459 self.network.debug_print(0, '', '(debug message, ignored)')
460 elif message.msgid == 'handshake':
461 self._parse_handshake(message)
77df9d06
SH
462 elif message.msgid == 'listbuffers':
463 self._parse_listbuffers(message)
464 elif message.msgid in ('listlines', '_buffer_line_added'):
465 self._parse_line(message)
466 elif message.msgid in ('_nicklist', 'nicklist'):
467 self._parse_nicklist(message)
468 elif message.msgid == '_nicklist_diff':
469 self._parse_nicklist_diff(message)
470 elif message.msgid == '_buffer_opened':
471 self._parse_buffer_opened(message)
472 elif message.msgid.startswith('_buffer_'):
473 self._parse_buffer(message)
474 elif message.msgid == '_upgrade':
475 self.network.desync_weechat()
476 elif message.msgid == '_upgrade_ended':
477 self.network.sync_weechat()
1f27a20e
AR
478 else:
479 print(f"Unknown message with id {message.msgid}")
77df9d06
SH
480
481 def create_buffer(self, item):
482 """Create a new buffer."""
483 buf = Buffer(item)
484 buf.bufferInput.connect(self.buffer_input)
485 buf.widget.input.bufferSwitchPrev.connect(
486 self.list_buffers.switch_prev_buffer)
487 buf.widget.input.bufferSwitchNext.connect(
488 self.list_buffers.switch_next_buffer)
489 return buf
490
491 def insert_buffer(self, index, buf):
492 """Insert a buffer in list."""
493 self.buffers.insert(index, buf)
7a3a48a5
AR
494 self.list_buffers.insertItem(index, '%s'
495 % (buf.data['local_variables']['name']))
77df9d06
SH
496 self.stacked_buffers.insertWidget(index, buf.widget)
497
498 def remove_buffer(self, index):
499 """Remove a buffer."""
500 if self.list_buffers.currentRow == index and index > 0:
501 self.list_buffers.setCurrentRow(index - 1)
502 self.list_buffers.takeItem(index)
503 self.stacked_buffers.removeWidget(self.stacked_buffers.widget(index))
504 self.buffers.pop(index)
505
506 def find_buffer_index_for_insert(self, next_buffer):
507 """Find position to insert a buffer in list."""
508 index = -1
509 if next_buffer == '0x0':
510 index = len(self.buffers)
511 else:
512 index = [i for i, b in enumerate(self.buffers)
513 if b.pointer() == next_buffer]
514 if index:
515 index = index[0]
516 if index < 0:
517 print('Warning: unable to find position for buffer, using end of '
518 'list by default')
519 index = len(self.buffers)
520 return index
521
522 def closeEvent(self, event):
523 """Called when QWeeChat window is closed."""
524 self.network.disconnect_weechat()
3b0947c9
SH
525 if self.network.debug_dialog:
526 self.network.debug_dialog.close()
77df9d06 527 config.write(self.config)
1f27a20e 528 QtWidgets.QMainWindow.closeEvent(self, event)
77df9d06 529
7a3a48a5 530
b8a96ada 531def main():
80f4f34c 532 app = QtWidgets.QApplication(sys.argv)
b8a96ada
AR
533 app.setStyle(QtWidgets.QStyleFactory.create('Cleanlooks'))
534 app.setWindowIcon(QtGui.QIcon(
535 resource_filename(__name__, 'data/icons/weechat.png')))
1fe88536
SH
536 main_win = MainWindow()
537 main_win.show()
b8a96ada 538 sys.exit(app.exec_())
77df9d06 539
b8a96ada
AR
540
541if __name__ == '__main__':
542 main()