basetabs.py 37.1 KB
Newer Older
1
"""
2 3 4 5 6 7 8 9 10 11 12 13
Module for the base Tabs

The root class Tab defines the generic interface and attributes of a
tab. A tab organizes various Windows around the screen depending
of the tab specificity. If the tab shows messages, it will also
reference a buffer containing the messages.

Each subclass should redefine its own refresh() and resize() method
according to its windows.

This module also defines ChatTabs, the parent class for all tabs
revolving around chats.
14 15
"""

16
import copy
17 18
import logging
import string
Madhur Garg's avatar
Madhur Garg committed
19
import asyncio
20
import time
Maxime Buquet's avatar
Maxime Buquet committed
21
from math import ceil, log10
Madhur Garg's avatar
Madhur Garg committed
22
from datetime import datetime
23
from xml.etree import cElementTree as ET
24
from typing import Any, Callable, Dict, List, Optional, Union
mathieui's avatar
mathieui committed
25

Maxime Buquet's avatar
Maxime Buquet committed
26
from poezio import mam, poopt, timed_events, xhtml, windows
27
from poezio.core.structs import Command, Completion, Status
28 29
from poezio.common import safeJID
from poezio.config import config
Maxime Buquet's avatar
Maxime Buquet committed
30
from poezio.decorators import command_args_parser, refresh_wrapper
31 32
from poezio.logger import logger
from poezio.text_buffer import TextBuffer
Maxime Buquet's avatar
Maxime Buquet committed
33 34 35 36
from poezio.theming import get_theme, dump_tuple
from poezio.windows.funcs import truncate_nick

from slixmpp import JID, InvalidJID, Message
37

mathieui's avatar
mathieui committed
38 39
log = logging.getLogger(__name__)

40
# getters for tab colors (lambdas, so that they are dynamic)
41
STATE_COLORS = {
mathieui's avatar
mathieui committed
42 43 44 45 46 47 48 49 50 51 52 53
    'disconnected': lambda: get_theme().COLOR_TAB_DISCONNECTED,
    'scrolled': lambda: get_theme().COLOR_TAB_SCROLLED,
    'nonempty': lambda: get_theme().COLOR_TAB_NONEMPTY,
    'joined': lambda: get_theme().COLOR_TAB_JOINED,
    'message': lambda: get_theme().COLOR_TAB_NEW_MESSAGE,
    'composing': lambda: get_theme().COLOR_TAB_COMPOSING,
    'highlight': lambda: get_theme().COLOR_TAB_HIGHLIGHT,
    'private': lambda: get_theme().COLOR_TAB_PRIVATE,
    'normal': lambda: get_theme().COLOR_TAB_NORMAL,
    'current': lambda: get_theme().COLOR_TAB_CURRENT,
    'attention': lambda: get_theme().COLOR_TAB_ATTENTION,
}
54
VERTICAL_STATE_COLORS = {
mathieui's avatar
mathieui committed
55 56 57 58 59 60 61 62 63 64 65 66
    'disconnected': lambda: get_theme().COLOR_VERTICAL_TAB_DISCONNECTED,
    'scrolled': lambda: get_theme().COLOR_VERTICAL_TAB_SCROLLED,
    'nonempty': lambda: get_theme().COLOR_VERTICAL_TAB_NONEMPTY,
    'joined': lambda: get_theme().COLOR_VERTICAL_TAB_JOINED,
    'message': lambda: get_theme().COLOR_VERTICAL_TAB_NEW_MESSAGE,
    'composing': lambda: get_theme().COLOR_VERTICAL_TAB_COMPOSING,
    'highlight': lambda: get_theme().COLOR_VERTICAL_TAB_HIGHLIGHT,
    'private': lambda: get_theme().COLOR_VERTICAL_TAB_PRIVATE,
    'normal': lambda: get_theme().COLOR_VERTICAL_TAB_NORMAL,
    'current': lambda: get_theme().COLOR_VERTICAL_TAB_CURRENT,
    'attention': lambda: get_theme().COLOR_VERTICAL_TAB_ATTENTION,
}
67

68 69
# priority of the different tab states when using Alt+e
# higher means more priority, < 0 means not selectable
70
STATE_PRIORITY = {
mathieui's avatar
mathieui committed
71 72 73 74 75 76 77 78 79 80 81 82
    'normal': -1,
    'current': -1,
    'disconnected': 0,
    'nonempty': 0.1,
    'scrolled': 0.5,
    'joined': 0.8,
    'composing': 0.9,
    'message': 1,
    'highlight': 2,
    'private': 2,
    'attention': 3
}
83

84
SHOW_NAME = {
mathieui's avatar
mathieui committed
85 86 87 88 89 90 91
    'dnd': 'busy',
    'away': 'away',
    'xa': 'not available',
    'chat': 'chatty',
    '': 'available'
}

92

93
class Tab:
94
    plugin_commands = {}  # type: Dict[str, Command]
mathieui's avatar
mathieui committed
95
    plugin_keys = {}  # type: Dict[str, Callable]
96 97 98
    # Placeholder values, set on resize
    height = 1
    width = 1
mathieui's avatar
mathieui committed
99

100 101
    def __init__(self, core):
        self.core = core
102
        self.nb = 0
103 104
        if not hasattr(self, 'name'):
            self.name = self.__class__.__name__
105
        self.input = None
mathieui's avatar
mathieui committed
106
        self.closed = False
107
        self._state = 'normal'
108
        self._prev_state = None
109

110
        self.need_resize = False
mathieui's avatar
mathieui committed
111 112 113
        self.key_func = {}  # each tab should add their keys in there
        # and use them in on_input
        self.commands = {}  # and their own commands
114

115
    @property
mathieui's avatar
mathieui committed
116
    def size(self) -> int:
117
        return self.core.size
118

119
    @staticmethod
mathieui's avatar
mathieui committed
120
    def tab_win_height() -> int:
121 122 123 124
        """
        Returns 1 or 0, depending on if we are using the vertical tab list
        or not.
        """
125
        if config.get('enable_vertical_tab_list'):
126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141
            return 0
        return 1

    @property
    def info_win(self):
        return self.core.information_win

    @property
    def color(self):
        return STATE_COLORS[self._state]()

    @property
    def vertical_color(self):
        return VERTICAL_STATE_COLORS[self._state]()

    @property
mathieui's avatar
mathieui committed
142
    def state(self) -> str:
143 144 145
        return self._state

    @state.setter
mathieui's avatar
mathieui committed
146
    def state(self, value: str):
147
        if value not in STATE_COLORS:
148 149 150 151
            log.debug("Invalid value for tab state: %s", value)
        elif STATE_PRIORITY[value] < STATE_PRIORITY[self._state] and \
                value not in ('current', 'disconnected') and \
                not (self._state == 'scrolled' and value == 'disconnected'):
mathieui's avatar
mathieui committed
152 153 154 155 156 157 158
            log.debug(
                "Did not set state because of lower priority, asked: %s, kept: %s",
                value, self._state)
        elif self._state == 'disconnected' and value not in ('joined',
                                                             'current'):
            log.debug(
                'Did not set state because disconnected tabs remain visible')
159 160
        else:
            self._state = value
161 162 163
            if self._state == 'current':
                self._prev_state = None

mathieui's avatar
mathieui committed
164
    def set_state(self, value: str):
165 166
        self._state = value

167 168 169 170 171 172 173 174 175 176
    def save_state(self):
        if self._state != 'composing':
            self._prev_state = self._state

    def restore_state(self):
        if self.state == 'composing' and self._prev_state:
            self._state = self._prev_state
            self._prev_state = None
        elif not self._prev_state:
            self._state = 'normal'
177 178 179

    @staticmethod
    def resize(scr):
180
        Tab.height, Tab.width = scr.getmaxyx()
181
        windows.base_wins.TAB_WIN = scr
182

183 184 185 186 187 188 189 190
    def missing_command_callback(self, command_name):
        """
        Callback executed when a command is not found.
        Returns True if the callback took care of displaying
        the error message, False otherwise.
        """
        return False

mathieui's avatar
mathieui committed
191
    def register_commands_batch(self, commands: List[Dict[str, Any]]):
192 193 194 195 196 197 198 199 200 201
        """
        Add several commands in a row, using a list of dictionaries
        """
        for command in commands:
            name = command['name']
            func = command['func']
            desc = command.get('desc', '')
            shortdesc = command.get('shortdesc', '')
            completion = command.get('completion')
            usage = command.get('usage', '')
mathieui's avatar
mathieui committed
202 203 204 205 206 207 208 209 210
            self.register_command(
                name,
                func,
                desc=desc,
                shortdesc=shortdesc,
                completion=completion,
                usage=usage)

    def register_command(self,
mathieui's avatar
mathieui committed
211 212
                         name: str,
                         func: Callable,
mathieui's avatar
mathieui committed
213 214 215
                         *,
                         desc='',
                         shortdesc='',
mathieui's avatar
mathieui committed
216
                         completion: Optional[Callable] = None,
mathieui's avatar
mathieui committed
217
                         usage=''):
218 219 220 221 222 223 224
        """
        Add a command
        """
        if name in self.commands:
            return
        if not desc and shortdesc:
            desc = shortdesc
225
        self.commands[name] = Command(func, desc, completion, shortdesc, usage)
226

mathieui's avatar
mathieui committed
227
    def complete_commands(self, the_input: windows.Input) -> bool:
228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244
        """
        Does command completion on the specified input for both global and tab-specific
        commands.
        This should be called from the completion method (on tab, for example), passing
        the input where completion is to be made.
        It can completion the command name itself or an argument of the command.
        Returns True if a completion was made, False else.
        """
        txt = the_input.get_text()
        # check if this is a command
        if txt.startswith('/') and not txt.startswith('//'):
            position = the_input.get_argument_position(quoted=False)
            if position == 0:
                words = ['/%s'% (name) for name in sorted(self.core.commands)] +\
                    ['/%s' % (name) for name in sorted(self.commands)]
                the_input.new_completion(words, 0)
                # Do not try to cycle command completion if there was only
245
                # one possibility. The next tab will complete the argument.
246 247 248 249 250 251 252 253 254 255 256 257 258 259 260
                # Otherwise we would need to add a useless space before being
                # able to complete the arguments.
                hit_copy = set(the_input.hit_list)
                if len(hit_copy) == 1:
                    the_input.do_command(' ')
                    the_input.reset_completion()
                return True
            # check if we are in the middle of the command name
            elif len(txt.split()) > 1 or\
                    (txt.endswith(' ') and not the_input.last_completion):
                command_name = txt.split()[0][1:]
                if command_name in self.commands:
                    command = self.commands[command_name]
                elif command_name in self.core.commands:
                    command = self.core.commands[command_name]
mathieui's avatar
mathieui committed
261
                else:  # Unknown command, cannot complete
262
                    return False
263
                if command.comp is None:
mathieui's avatar
mathieui committed
264
                    return False  # There's no completion function
mathieui's avatar
mathieui committed
265 266 267 268
                comp = command.comp(the_input)
                if comp:
                    return comp.run()
                return comp
269 270
        return False

mathieui's avatar
mathieui committed
271
    def execute_command(self, provided_text: str) -> bool:
272 273 274 275 276 277 278 279
        """
        Execute the command in the input and return False if
        the input didn't contain a command
        """
        txt = provided_text or self.input.key_enter()
        if txt.startswith('/') and not txt.startswith('//') and\
                not txt.startswith('/me '):
            command = txt.strip().split()[0][1:]
mathieui's avatar
mathieui committed
280
            arg = txt[2 + len(command):]  # jump the '/' and the ' '
281
            func = None
mathieui's avatar
mathieui committed
282
            if command in self.commands:  # check tab-specific commands
283
                func = self.commands[command].func
mathieui's avatar
mathieui committed
284
            elif command in self.core.commands:  # check global commands
285
                func = self.core.commands[command].func
286 287 288
            else:
                low = command.lower()
                if low in self.commands:
289
                    func = self.commands[low].func
290
                elif low in self.core.commands:
291
                    func = self.core.commands[low].func
292
                else:
293 294 295
                    if self.missing_command_callback is not None:
                        error_handled = self.missing_command_callback(low)
                    if not error_handled:
mathieui's avatar
mathieui committed
296 297
                        self.core.information(
                            "Unknown command (%s)" % (command), 'Error')
mathieui's avatar
mathieui committed
298
            if command in ('correct', 'say'):  # hack
299 300 301 302
                arg = xhtml.convert_simple_to_full_colors(arg)
            else:
                arg = xhtml.clean_text_simple(arg)
            if func:
303 304
                if hasattr(self.input, "reset_completion"):
                    self.input.reset_completion()
305 306 307 308 309 310
                func(arg)
            return True
        else:
            return False

    def refresh_tab_win(self):
311
        if config.get('enable_vertical_tab_list'):
312 313 314
            left_tab_win = self.core.left_tab_win
            if left_tab_win and not self.size.core_degrade_x:
                left_tab_win.refresh()
315
        elif not self.size.core_degrade_y:
316
            self.core.tab_win.refresh()
317

318 319 320 321 322 323
    def refresh_input(self):
        """Refresh the current input if any"""
        if self.input is not None:
            self.input.refresh()
            self.core.doupdate()

324 325 326 327 328 329 330 331 332 333
    def refresh(self):
        """
        Called on each screen refresh (when something has changed)
        """
        pass

    def get_name(self):
        """
        get the name of the tab
        """
334
        return self.name
335

mathieui's avatar
mathieui committed
336
    def get_nick(self) -> str:
337 338 339
        """
        Get the nick of the tab (defaults to its name)
        """
340
        return self.name
341

mathieui's avatar
mathieui committed
342
    def get_text_window(self) -> Optional[windows.TextWin]:
343 344 345 346 347
        """
        Returns the principal TextWin window, if there's one
        """
        return None

mathieui's avatar
mathieui committed
348
    def on_input(self, key: str, raw: bool):
349 350 351 352 353 354 355
        """
        raw indicates if the key should activate the associated command or not.
        """
        pass

    def update_commands(self):
        for c in self.plugin_commands:
356
            if c not in self.commands:
357 358 359 360
                self.commands[c] = self.plugin_commands[c]

    def update_keys(self):
        for k in self.plugin_keys:
361
            if k not in self.key_func:
362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413
                self.key_func[k] = self.plugin_keys[k]

    def on_lose_focus(self):
        """
        called when this tab loses the focus.
        """
        self.state = 'normal'

    def on_gain_focus(self):
        """
        called when this tab gains the focus.
        """
        self.state = 'current'

    def on_scroll_down(self):
        """
        Defines what happens when we scroll down
        """
        pass

    def on_scroll_up(self):
        """
        Defines what happens when we scroll up
        """
        pass

    def on_line_up(self):
        """
        Defines what happens when we scroll one line up
        """
        pass

    def on_line_down(self):
        """
        Defines what happens when we scroll one line up
        """
        pass

    def on_half_scroll_down(self):
        """
        Defines what happens when we scroll half a screen down
        """
        pass

    def on_half_scroll_up(self):
        """
        Defines what happens when we scroll half a screen up
        """
        pass

    def on_info_win_size_changed(self):
        """
414
        Called when the window with the information is resized
415 416 417 418 419 420 421 422 423
        """
        pass

    def on_close(self):
        """
        Called when the tab is to be closed
        """
        if self.input:
            self.input.on_delete()
mathieui's avatar
mathieui committed
424
        self.closed = True
425

mathieui's avatar
mathieui committed
426
    def matching_names(self) -> List[str]:
427 428 429 430 431 432 433 434 435 436 437 438
        """
        Returns a list of strings that are used to name a tab with the /win
        command.  For example you could switch to a tab that returns
        ['hello', 'coucou'] using /win hel, or /win coucou
        If not implemented in the tab, it just doesn’t match with anything.
        """
        return []

    def __del__(self):
        log.debug('------ Closing tab %s', self.__class__.__name__)


mathieui's avatar
mathieui committed
439
class GapTab(Tab):
440 441 442 443 444 445
    def __bool__(self):
        return False

    def __len__(self):
        return 0

446 447
    @property
    def name(self):
448 449 450
        return ''

    def refresh(self):
mathieui's avatar
mathieui committed
451 452 453
        log.debug(
            'WARNING: refresh() called on a gap tab, this should not happen')

454 455 456 457 458 459 460 461

class ChatTab(Tab):
    """
    A tab containing a chat of any type.
    Just use this class instead of Tab if the tab needs a recent-words completion
    Also, ^M is already bound to on_enter
    And also, add the /say command
    """
462 463
    plugin_commands = {}  # type: Dict[str, Command]
    plugin_keys = {}  # type: Dict[str, Callable]
mathieui's avatar
mathieui committed
464
    message_type = 'chat'
mathieui's avatar
mathieui committed
465

466
    def __init__(self, core, jid: Union[JID, str]):
467
        Tab.__init__(self, core)
468 469 470 471 472

        if not isinstance(jid, JID):
            jid = JID(jid)
        assert jid.domain
        self._jid = jid
473
        #: Is the tab currently requesting MAM data?
474
        self.query_status = False
475
        self.last_stanza_id = None
476

477
        self._name = jid.full  # type: Optional[str]
478
        self.text_win = None
mathieui's avatar
mathieui committed
479
        self.directed_presence = None
480
        self._text_buffer = TextBuffer()
mathieui's avatar
mathieui committed
481
        self.chatstate = None  # can be "active", "composing", "paused", "gone", "inactive"
louiz’'s avatar
louiz’ committed
482
        # We keep a reference of the event that will set our chatstate to "paused", so that
483 484
        # we can delete it or change it if we need to
        self.timed_event_paused = None
485
        self.timed_event_not_paused = None
486
        # Keeps the last sent message to complete it easily in completion_correct, and to replace it.
mathieui's avatar
mathieui committed
487
        self.last_sent_message = {}
488 489 490 491
        self.key_func['M-v'] = self.move_separator
        self.key_func['M-h'] = self.scroll_separator
        self.key_func['M-/'] = self.last_words_completion
        self.key_func['^M'] = self.on_enter
mathieui's avatar
mathieui committed
492 493 494 495 496
        self.register_command(
            'say',
            self.command_say,
            usage='<message>',
            shortdesc='Send the message.')
Madhur Garg's avatar
Madhur Garg committed
497
        self.register_command(
498
            'scrollback',
Maxime Buquet's avatar
Maxime Buquet committed
499
            self.command_scrollback,
500
            usage="end home clear status goto <+|-linecount>|<linenum>|<timestamp>",
501
            shortdesc='Scrollback to the given line number, message, or clear the buffer.')
502
        self.commands['sb'] = self.commands['scrollback']
mathieui's avatar
mathieui committed
503 504 505 506 507 508 509 510 511 512 513 514 515
        self.register_command(
            'xhtml',
            self.command_xhtml,
            usage='<custom xhtml>',
            shortdesc='Send custom XHTML.')
        self.register_command(
            'clear', self.command_clear, shortdesc='Clear the current buffer.')
        self.register_command(
            'correct',
            self.command_correct,
            desc='Fix the last message with whatever you want.',
            shortdesc='Correct the last message.',
            completion=self.completion_correct)
516 517 518 519
        self.chat_state = None
        self.update_commands()
        self.update_keys()

520 521
    @property
    def name(self) -> str:
522 523 524
        if self._name is not None:
            return self._name
        return self._jid.full
525 526 527 528

    @name.setter
    def name(self, value: Union[JID, str]) -> None:
        if isinstance(value, JID):
529
            self.jid = value
530 531 532 533
        elif isinstance(value, str):
            try:
                value = JID(value)
                if value.domain:
534
                    self._jid = value
535
            except InvalidJID:
536
                self._name = value
537
        else:
538
            raise TypeError("Name %r must be of type JID or str." % value)
539 540

    @property
541
    def jid(self) -> JID:
542
        return copy.copy(self._jid)
543 544

    @jid.setter
545
    def jid(self, value: JID) -> None:
546
        if not isinstance(value, JID):
547 548
            raise TypeError("Jid %r must be of type JID." % value)
        assert value.domain
549
        self._jid = value
550

mathieui's avatar
mathieui committed
551
    @property
mathieui's avatar
mathieui committed
552
    def general_jid(self) -> JID:
553
        raise NotImplementedError
mathieui's avatar
mathieui committed
554

mathieui's avatar
mathieui committed
555 556 557 558 559
    def log_message(self,
                    txt: str,
                    nickname: str,
                    time: Optional[datetime] = None,
                    typ=1):
560 561 562
        """
        Log the messages in the archives.
        """
563 564 565
        name = self.jid.bare
        if not logger.log_message(name, nickname, txt, date=time, typ=typ):
            self.core.information('Unable to write in the log file', 'Error')
566

mathieui's avatar
mathieui committed
567 568 569 570 571 572 573 574 575 576 577
    def add_message(self,
                    txt,
                    time=None,
                    nickname=None,
                    forced_user=None,
                    nick_color=None,
                    identifier=None,
                    jid=None,
                    history=None,
                    typ=1,
                    highlight=False):
578
        self.log_message(txt, nickname, time=time, typ=typ)
mathieui's avatar
mathieui committed
579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596
        self._text_buffer.add_message(
            txt,
            time=time,
            nickname=nickname,
            highlight=highlight,
            nick_color=nick_color,
            history=history,
            user=forced_user,
            identifier=identifier,
            jid=jid)

    def modify_message(self,
                       txt,
                       old_id,
                       new_id,
                       user=None,
                       jid=None,
                       nickname=None):
597
        self.log_message(txt, nickname, typ=1)
mathieui's avatar
mathieui committed
598 599
        message = self._text_buffer.modify_message(
            txt, old_id, new_id, time=time, user=user, jid=jid)
600 601 602 603 604 605 606 607 608 609 610
        if message:
            self.text_win.modify_message(old_id, message)
            self.core.refresh_window()
            return True
        return False

    def last_words_completion(self):
        """
        Complete the input with words recently said
        """
        # build the list of the recent words
mathieui's avatar
mathieui committed
611
        char_we_dont_want = string.punctuation + ' ’„“”…«»'
612
        words = []
613 614 615 616 617 618 619 620 621
        for msg in self._text_buffer.messages[:-40:-1]:
            if not msg:
                continue
            txt = xhtml.clean_text(msg.txt)
            for char in char_we_dont_want:
                txt = txt.replace(char, ' ')
            for word in txt.split():
                if len(word) >= 4 and word not in words:
                    words.append(word)
622
        words.extend([word for word in config.get('words').split(':') if word])
623 624 625 626 627 628 629 630 631 632 633
        self.input.auto_completion(words, ' ', quotify=False)

    def on_enter(self):
        txt = self.input.key_enter()
        if txt:
            if not self.execute_command(txt):
                if txt.startswith('//'):
                    txt = txt[1:]
                self.command_say(xhtml.convert_simple_to_full_colors(txt))
        self.cancel_paused_delay()

634 635
    @command_args_parser.raw
    def command_xhtml(self, xhtml):
636 637 638
        """"
        /xhtml <custom xhtml>
        """
639
        message = self.generate_xhtml_message(xhtml)
640 641 642
        if message:
            message.send()

mathieui's avatar
mathieui committed
643
    def generate_xhtml_message(self, arg: str) -> Message:
644 645 646
        if not arg:
            return
        try:
mathieui's avatar
mathieui committed
647 648
            body = xhtml.clean_text(
                xhtml.xhtml_to_poezio_colors(arg, force=True))
649 650 651 652 653 654 655 656 657 658 659 660
            ET.fromstring(arg)
        except:
            self.core.information('Could not send custom xhtml', 'Error')
            log.error('/xhtml: Unable to send custom xhtml', exc_info=True)
            return

        msg = self.core.xmpp.make_message(self.get_dest_jid())
        msg['body'] = body
        msg.enable('html')
        msg['html']['body'] = arg
        return msg

mathieui's avatar
mathieui committed
661
    def get_dest_jid(self) -> JID:
662
        return self.jid
663 664

    @refresh_wrapper.always
665
    def command_clear(self, ignored):
666 667 668 669 670 671
        """
        /clear
        """
        self._text_buffer.messages = []
        self.text_win.rebuild_everything(self._text_buffer)

mathieui's avatar
mathieui committed
672 673 674 675
    def check_send_chat_state(self):
        "If we should send a chat state"
        return True

676 677 678 679
    def send_chat_state(self, state, always_send=False):
        """
        Send an empty chatstate message
        """
mathieui's avatar
mathieui committed
680
        if self.check_send_chat_state():
mathieui's avatar
mathieui committed
681 682
            if state in ('active', 'inactive',
                         'gone') and self.inactive and not always_send:
683
                return
684
            if config.get_by_tabname('send_chat_states', self.general_jid):
685 686 687 688 689
                msg = self.core.xmpp.make_message(self.get_dest_jid())
                msg['type'] = self.message_type
                msg['chat_state'] = state
                self.chat_state = state
                msg.send()
690
                return True
691 692 693 694 695 696 697

    def send_composing_chat_state(self, empty_after):
        """
        Send the "active" or "composing" chatstate, depending
        on the the current status of the input
        """
        name = self.general_jid
698
        if config.get_by_tabname('send_chat_states', name):
699 700 701 702 703 704 705 706 707 708 709 710 711 712
            needed = 'inactive' if self.inactive else 'active'
            self.cancel_paused_delay()
            if not empty_after:
                if self.chat_state != "composing":
                    self.send_chat_state("composing")
                self.set_paused_delay(True)
            elif empty_after and self.chat_state != needed:
                self.send_chat_state(needed, True)

    def set_paused_delay(self, composing):
        """
        we create a timed event that will put us to paused
        in a few seconds
        """
713
        if not config.get_by_tabname('send_chat_states', self.general_jid):
714
            return
715 716 717
        # First, cancel the delay if it already exists, before rescheduling
        # it at a new date
        self.cancel_paused_delay()
mathieui's avatar
mathieui committed
718 719
        new_event = timed_events.DelayedEvent(4, self.send_chat_state,
                                              'paused')
720
        self.core.add_timed_event(new_event)
721
        self.timed_event_paused = new_event
mathieui's avatar
mathieui committed
722 723 724
        new_event = timed_events.DelayedEvent(
            30, self.send_chat_state, 'inactive'
            if self.inactive else 'active')
725
        self.core.add_timed_event(new_event)
726
        self.timed_event_not_paused = new_event
727 728 729 730 731 732 733

    def cancel_paused_delay(self):
        """
        Remove that event from the list and set it to None.
        Called for example when the input is emptied, or when the message
        is sent
        """
734 735 736
        if self.timed_event_paused is not None:
            self.core.remove_timed_event(self.timed_event_paused)
            self.timed_event_paused = None
737 738
            self.core.remove_timed_event(self.timed_event_not_paused)
            self.timed_event_not_paused = None
739

740
    @command_args_parser.raw
741 742 743 744 745
    def command_correct(self, line):
        """
        /correct <fixed message>
        """
        if not line:
746
            self.core.command.help('correct')
747 748
            return
        if not self.last_sent_message:
749
            self.core.information('There is no message to correct.', 'Error')
750 751 752 753 754
            return
        self.command_say(line, correct=True)

    def completion_correct(self, the_input):
        if self.last_sent_message and the_input.get_argument_position() == 1:
mathieui's avatar
mathieui committed
755 756 757 758
            return Completion(
                the_input.auto_completion, [self.last_sent_message['body']],
                '',
                quotify=False)
759
        return True
760 761

    @property
mathieui's avatar
mathieui committed
762
    def inactive(self) -> bool:
763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779
        """Whether we should send inactive or active as a chatstate"""
        return self.core.status.show in ('xa', 'away') or\
                (hasattr(self, 'directed_presence') and not self.directed_presence)

    def move_separator(self):
        self.text_win.remove_line_separator()
        self.text_win.add_line_separator(self._text_buffer)
        self.text_win.refresh()
        self.input.refresh()

    def get_conversation_messages(self):
        return self._text_buffer.messages

    def check_scrolled(self):
        if self.text_win.pos != 0:
            self.state = 'scrolled'

780
    @command_args_parser.raw
781 782 783
    def command_say(self, line, correct=False):
        pass

784 785 786 787
    def goto_build_lines(self, new_date):
        text_buffer = self._text_buffer
        built_lines = []
        message_count = 0
788 789
        timestamp = config.get('show_timestamps')
        nick_size = config.get('max_nick_length')
790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828
        for message in text_buffer.messages:
            # Build lines of a message
            txt = message.txt
            nick = truncate_nick(message.nickname, nick_size)
            offset = 0
            theme = get_theme()
            if message.ack:
                if message.ack > 0:
                    offset += poopt.wcswidth(theme.CHAR_ACK_RECEIVED) + 1
                else:
                    offset += poopt.wcswidth(theme.CHAR_NACK) + 1
            if nick:
                offset += poopt.wcswidth(nick) + 2
            if message.revisions > 0:
                offset += ceil(log10(message.revisions + 1))
            if message.me:
                offset += 1
            if timestamp:
                if message.str_time:
                    offset += 1 + len(message.str_time)
                if theme.CHAR_TIME_LEFT and message.str_time:
                    offset += 1
                if theme.CHAR_TIME_RIGHT and message.str_time:
                    offset += 1
            lines = poopt.cut_text(txt, self.text_win.width - offset - 1)
            for line in lines:
                built_lines.append(line)
            # Find the message with timestamp less than or equal to the queried
            # timestamp and goto that location in the tab.
            if message.time <= new_date:
                message_count += 1
                if len(self.text_win.built_lines) - self.text_win.height >= len(built_lines):
                    self.text_win.pos = len(self.text_win.built_lines) - self.text_win.height - len(built_lines) + 1
                else:
                    self.text_win.pos = 0
        if message_count == 0:
            self.text_win.scroll_up(len(self.text_win.built_lines))
        self.core.refresh_window()

Madhur Garg's avatar
Madhur Garg committed
829
    @command_args_parser.quoted(0, 2)
Maxime Buquet's avatar
Maxime Buquet committed
830
    def command_scrollback(self, args):
Madhur Garg's avatar
Madhur Garg committed
831
        """
832 833 834 835 836
        /sb clear
        /sb home
        /sb end
        /sb goto <+|-linecount>|<linenum>|<timestamp>
        The format of timestamp must be ‘[dd[.mm]-<days ago>] hh:mi[:ss]’
Madhur Garg's avatar
Madhur Garg committed
837 838
        """
        if args is None or len(args) == 0:
Madhur Garg's avatar
Madhur Garg committed
839 840
            args = ['end']
        if len(args) == 1:
Madhur Garg's avatar
Madhur Garg committed
841 842 843
            if args[0] == 'end':
                self.text_win.scroll_down(len(self.text_win.built_lines))
                self.core.refresh_window()
844
                return
Madhur Garg's avatar
Madhur Garg committed
845 846 847
            elif args[0] == 'home':
                self.text_win.scroll_up(len(self.text_win.built_lines))
                self.core.refresh_window()
848
                return
Madhur Garg's avatar
Madhur Garg committed
849 850 851 852
            elif args[0] == 'clear':
                self._text_buffer.messages = []
                self.text_win.rebuild_everything(self._text_buffer)
                self.core.refresh_window()
853
                return
Madhur Garg's avatar
Madhur Garg committed
854
            elif args[0] == 'status':
855
                self.core.information('Total %s lines in this tab.' % len(self.text_win.built_lines), 'Info')
856
                return
Madhur Garg's avatar
Madhur Garg committed
857
        elif len(args) == 2 and args[0] == 'goto':
858 859 860
            for fmt in ('%d %H:%M', '%d %H:%M:%S', '%d:%m %H:%M', '%d:%m %H:%M:%S', '%H:%M', '%H:%M:%S'):
                try:
                    new_date = datetime.strptime(args[1], fmt)
861
                    if 'd' in fmt and 'm' in fmt:
862 863 864 865 866 867 868
                        new_date = new_date.replace(year=datetime.now().year)
                    elif 'd' in fmt:
                        new_date = new_date.replace(year=datetime.now().year, month=datetime.now().month)
                    else:
                        new_date = new_date.replace(year=datetime.now().year, month=datetime.now().month, day=datetime.now().day)
                except ValueError:
                    pass
869
            if args[1].startswith('-'):
870
                # Check if the user is giving argument of type goto <-linecount> or goto [-<days ago>] hh:mi[:ss]
871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888
                if ' ' in args[1]:
                    new_args = args[1].split(' ')
                    new_args[0] = new_args[0].strip('-')
                    new_date = datetime.now()
                    if new_args[0].isdigit():
                        new_date = new_date.replace(day=new_date.day - int(new_args[0]))
                    for fmt in ('%H:%M', '%H:%M:%S'):
                        try:
                            arg_date = datetime.strptime(new_args[1], fmt)
                            new_date = new_date.replace(hour=arg_date.hour, minute=arg_date.minute, second=arg_date.second)
                        except ValueError:
                            pass
                else:
                    scroll_len = args[1].strip('-')
                    if scroll_len.isdigit():
                        self.text_win.scroll_down(int(scroll_len))
                        self.core.refresh_window()
                        return
889
            elif args[1].startswith('+'):
890 891
                scroll_len = args[1].strip('+')
                if scroll_len.isdigit():
Madhur Garg's avatar
Madhur Garg committed
892 893 894
                    self.text_win.scroll_up(int(scroll_len))
                    self.core.refresh_window()
                    return
895
            # Check for the argument of type goto <linenum>
896 897 898
            elif args[1].isdigit():
                if len(self.text_win.built_lines) - self.text_win.height >= int(args[1]):
                    self.text_win.pos = len(self.text_win.built_lines) - self.text_win.height - int(args[1])
Madhur Garg's avatar
Madhur Garg committed
899 900
                    self.core.refresh_window()
                    return
Madhur Garg's avatar
Madhur Garg committed
901 902 903 904 905 906
                else:
                    self.text_win.pos = 0
                    self.core.refresh_window()
                    return
            elif args[1] == '0':
                args = ['home']
907 908
            # new_date is the timestamp for which the user has queried.
            self.goto_build_lines(new_date)
Madhur Garg's avatar
Madhur Garg committed
909

910 911 912 913 914 915 916
    def on_line_up(self):
        return self.text_win.scroll_up(1)

    def on_line_down(self):
        return self.text_win.scroll_down(1)

    def on_scroll_up(self):
917
        if not self.query_status:
Madhur Garg's avatar
Madhur Garg committed
918
            asyncio.ensure_future(mam.on_scroll_up(tab=self))
919
        return self.text_win.scroll_up(self.text_win.height - 1)
920 921

    def on_scroll_down(self):
mathieui's avatar
mathieui committed
922
        return self.text_win.scroll_down(self.text_win.height - 1)
923 924

    def on_half_scroll_up(self):
mathieui's avatar
mathieui committed
925
        return self.text_win.scroll_up((self.text_win.height - 1) // 2)
926 927

    def on_half_scroll_down(self):
mathieui's avatar
mathieui committed
928
        return self.text_win.scroll_down((self.text_win.height - 1) // 2)
929 930 931 932 933

    @refresh_wrapper.always
    def scroll_separator(self):
        self.text_win.scroll_to_separator()

934

mathieui's avatar
mathieui committed
935
class OneToOneTab(ChatTab):
936
    def __init__(self, core, jid):
937
        ChatTab.__init__(self, core, jid)
938

939 940 941
        self.__status = Status("", "")
        self.last_remote_message = datetime.now()

942 943
        # Set to true once the first disco is done
        self.__initial_disco = False
944
        self.check_features()
mathieui's avatar
mathieui committed
945 946 947 948
        self.register_command(
            'unquery', self.command_unquery, shortdesc='Close the tab.')
        self.register_command(
            'close', self.command_unquery, shortdesc='Close the tab.')
949
        self.register_command(
mathieui's avatar
mathieui committed
950 951
            'attention',
            self.command_attention,
952 953 954
            usage='[message]',
            shortdesc='Request the attention.',
            desc='Attention: Request the attention of the contact.  Can also '
mathieui's avatar
mathieui committed
955
            'send a message along with the attention.')
956

957 958 959 960 961
    def remote_user_color(self):
        return dump_tuple(get_theme().COLOR_REMOTE_USER)

    def update_status(self, status):
        old_status = self.__status
mathieui's avatar
mathieui committed
962 963
        if not (old_status.show != status.show
                or old_status.message != status.message):
964 965 966
            return
        self.__status = status
        hide_status_change = config.get_by_tabname('hide_status_change',
967
                                                   self.jid.bare)
968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983
        now = datetime.now()
        dff = now - self.last_remote_message
        if hide_status_change > -1 and dff.total_seconds() > hide_status_change:
            return

        info_c = dump_tuple(get_theme().COLOR_INFORMATION_TEXT)
        nick = self.get_nick()
        remote = self.remote_user_color()
        msg = '\x19%(color)s}%(nick)s\x19%(info)s} changed: '
        msg %= {'color': remote, 'nick': nick, 'info': info_c}
        if status.message != old_status.message and status.message:
            msg += 'status: %s, ' % status.message
        if status.show in SHOW_NAME:
            msg += 'show: %s, ' % SHOW_NAME[status.show]
        self.add_message(msg[:-2], typ=2)

984
    def ack_message(self, msg_id: str, msg_jid: JID):
985 986 987
        """
        Ack a message
        """
988
        new_msg = self._text_buffer.ack_message(msg_id, msg_jid)
989 990 991 992
        if new_msg:
            self.text_win.modify_message(msg_id, new_msg)
            self.core.refresh_window()

993
    def nack_message(self, error: str, msg_id: str, msg_jid: JID):
994
        """
995
        Non-ack a message (e.g. timeout)
996 997 998 999 1000 1001 1002 1003
        """
        new_msg = self._text_buffer.nack_message(error, msg_id, msg_jid)
        if new_msg:
            self.text_win.modify_message(msg_id, new_msg)
            self.core.refresh_window()
            return True
        return False

1004 1005 1006 1007
    @command_args_parser.raw
    def command_xhtml(self, xhtml_data):
        message = self.generate_xhtml_message(xhtml_data)
        if message:
1008
            message['type'] = 'chat'
1009
            message._add_receipt = True
1010
            message['chat_sate'] = 'active'
1011 1012
            message.send()
            body = xhtml.xhtml_to_poezio_colors(xhtml_data, force=True)
mathieui's avatar
mathieui committed
1013 1014 1015
            self._text_buffer.add_message(
                body,
                nickname=self.core.own_nick,
1016
                nick_color=get_theme().COLOR_OWN_NICK,
mathieui's avatar
mathieui committed
1017
                identifier=message['id'],
mathieui's avatar
mathieui committed
1018
                jid=self.core.xmpp.boundjid)
1019 1020
            self.refresh()

1021 1022
    def check_features(self):
        "check the features supported by the other party"
1023 1024
        if safeJID(self.get_dest_jid()).resource:
            self.core.xmpp.plugin['xep_0030'].get_info(
mathieui's avatar
mathieui committed
1025 1026 1027
                jid=self.get_dest_jid(),
                timeout=5,
                callback=self.features_checked)
1028

1029 1030 1031
    @command_args_parser.raw
    def command_attention(self, message):
        """/attention [message]"""
1032 1033 1034 1035 1036 1037 1038 1039
        if message is not '':
            self.command_say(message, attention=True)
        else:
            msg = self.core.xmpp.make_message(self.get_dest_jid())
            msg['type'] = 'chat'
            msg['attention'] = True
            msg.send()

1040
    @command_args_parser.raw
1041 1042 1043
    def command_say(self, line, correct=False, attention=False):
        pass

1044 1045 1046 1047 1048
    @command_args_parser.ignored
    def command_unquery(self):
        """
        /unquery
        """
mathieui's avatar
mathieui committed
1049
        self.send_chat_state('gone', always_send=True)
1050 1051
        self.core.close_tab(self)

1052 1053 1054 1055 1056
    def missing_command_callback(self, command_name):
        if command_name not in ('correct', 'attention'):
            return False

        if command_name == 'correct':
1057
            feature = 'message correction'
1058
        elif command_name == 'attention':
1059 1060 1061
            feature = 'attention requests'
        msg = ('%s does not support %s, therefore the /%s '
               'command is currently disabled in this tab.')
1062 1063 1064 1065
        msg = msg % (self.name, feature, command_name)
        self.core.information(msg, 'Info')
        return True

1066 1067 1068
    def features_checked(self, iq):
        "Features check callback"
        features = iq['disco_info'].get_features() or []